feat: create a new canvas by copying an existing one

Add a 'Copy from existing' option to the New Canvas modal. It lists every
canvas with node/group/text counts; picking one deep-copies its nodes, edges,
parent/child links and canvas state (viewport, custom style, floor plan) into
a fresh design.

Backend: POST /designs/{source_id}/copy remaps node ids, re-points edges and
parent links, and clones canvas state; GET /designs now returns per-design
counts for the picker. Standalone mode clones the localStorage canvas.

Closes #216

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-07 15:53:42 +02:00
parent 6b591cdd88
commit 0b4bd5680d
10 changed files with 493 additions and 8 deletions
+2
View File
@@ -169,6 +169,8 @@ export const designsApi = {
list: () => api.get<import('@/types').Design[]>('/designs'),
create: (data: { name: string; icon?: string; design_type?: string }) =>
api.post<import('@/types').Design>('/designs', data),
copy: (sourceId: string, data: { name: string; icon?: string }) =>
api.post<import('@/types').Design>(`/designs/${sourceId}/copy`, data),
update: (id: string, data: { name?: string; icon?: string }) =>
api.put<import('@/types').Design>(`/designs/${id}`, data),
delete: (id: string) => api.delete(`/designs/${id}`),
+84 -2
View File
@@ -3,8 +3,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import type { FloorMapConfig } from '@/types'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
import type { Design, FloorMapConfig } from '@/types'
export interface DesignFormData {
name: string
@@ -15,6 +15,11 @@ export interface DesignFormData {
* plan"; `undefined` means "leave it untouched".
*/
floorMap?: FloorMapConfig | null
/**
* When set, create the new canvas by deep-copying this existing design instead
* of starting blank. Only offered in create mode with `sourceDesigns` present.
*/
sourceId?: string
}
interface DesignModalProps {
@@ -34,6 +39,11 @@ interface DesignModalProps {
* base64. Rejects on failure (caller surfaces the error).
*/
onUploadImage?: (file: File) => Promise<string>
/**
* Existing designs offered as a copy source (create mode only). When non-empty,
* a "Copy from existing" option appears; choosing it clones the picked canvas.
*/
sourceDesigns?: Design[]
}
export function DesignModal({
@@ -46,10 +56,16 @@ export function DesignModal({
showFloorMap = false,
initialFloorMap = null,
onUploadImage,
sourceDesigns = [],
}: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
// "Copy from existing" is create-mode only (no floor-plan section shown).
const canCopy = !showFloorMap && sourceDesigns.length > 0
const [fromExisting, setFromExisting] = useState(false)
const [sourceId, setSourceId] = useState<string>(sourceDesigns[0]?.id ?? '')
// Floor plan state (only used when showFloorMap)
const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '')
const [width, setWidth] = useState(initialFloorMap?.width ?? 800)
@@ -85,7 +101,11 @@ export function DesignModal({
const handleSubmit = () => {
const trimmed = name.trim()
if (!trimmed) return
if (canCopy && fromExisting && !sourceId) return
const data: DesignFormData = { name: trimmed, icon }
if (canCopy && fromExisting && sourceId) {
data.sourceId = sourceId
}
if (showFloorMap) {
data.floorMap = imageData
? {
@@ -153,6 +173,68 @@ export function DesignModal({
</div>
</div>
{canCopy && (
<div className="space-y-2 pt-2 border-t border-border">
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
aria-pressed={!fromExisting}
onClick={() => setFromExisting(false)}
className={`text-xs rounded-md border py-2 transition-colors cursor-pointer ${
!fromExisting
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
>
Blank canvas
</button>
<button
type="button"
aria-pressed={fromExisting}
onClick={() => setFromExisting(true)}
className={`text-xs rounded-md border py-2 transition-colors cursor-pointer ${
fromExisting
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
>
Copy from existing
</button>
</div>
{fromExisting && (
<div className="space-y-1 max-h-48 overflow-y-auto pr-1" role="radiogroup" aria-label="Source canvas">
{sourceDesigns.map((d) => {
const Icon = resolveDesignIcon(d.icon)
const selected = d.id === sourceId
return (
<button
key={d.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setSourceId(d.id)}
className={`w-full flex items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10'
: 'border-border hover:border-[#30363d]'
}`}
>
<Icon size={16} className={selected ? 'text-[#00d4ff]' : 'text-muted-foreground'} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{d.name}</div>
<div className="text-xs text-muted-foreground">
{d.node_count ?? 0} nodes · {d.group_count ?? 0} groups · {d.text_count ?? 0} text
</div>
</div>
</button>
)
})}
</div>
)}
</div>
)}
{showFloorMap && (
<div className="space-y-2 pt-2 border-t border-border">
<Label>Floor Plan</Label>
@@ -65,6 +65,60 @@ describe('DesignModal', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
describe('copy from existing', () => {
const sourceDesigns = [
{ id: 's1', name: 'Home Net', icon: 'network', design_type: 'network' as const,
created_at: '', updated_at: '', node_count: 4, group_count: 1, text_count: 2 },
{ id: 's2', name: 'Lab', icon: 'server', design_type: 'network' as const,
created_at: '', updated_at: '', node_count: 7, group_count: 0, text_count: 0 },
]
it('offers no copy option when there are no source designs', () => {
renderModal({ sourceDesigns: [] })
expect(screen.queryByRole('button', { name: 'Copy from existing' })).toBeNull()
})
it('shows the source list with counts once "Copy from existing" is chosen', () => {
renderModal({ sourceDesigns })
// Hidden until the user opts into copying.
expect(screen.queryByText('Home Net')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
expect(screen.getByText('Home Net')).toBeDefined()
expect(screen.getByText('4 nodes · 1 groups · 2 text')).toBeDefined()
expect(screen.getByText('7 nodes · 0 groups · 0 text')).toBeDefined()
})
it('includes sourceId (first design by default) on submit', () => {
const { onSubmit } = renderModal({ sourceDesigns })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Home' } })
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, sourceId: 's1' })
})
it('includes the picked sourceId on submit', () => {
const { onSubmit } = renderModal({ sourceDesigns })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Lab' } })
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
fireEvent.click(screen.getByRole('radio', { name: /Lab/ }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, sourceId: 's2' })
})
it('omits sourceId when the blank option is kept', () => {
const { onSubmit } = renderModal({ sourceDesigns })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON })
expect('sourceId' in onSubmit.mock.calls[0][0]).toBe(false)
})
it('is hidden in edit mode (floor-plan shown)', () => {
renderModal({ sourceDesigns, showFloorMap: true, initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON } })
expect(screen.queryByRole('button', { name: 'Copy from existing' })).toBeNull()
})
})
describe('floor plan section', () => {
const fm = {
imageData: 'data:image/png;base64,abc',
+16 -3
View File
@@ -56,9 +56,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
if (!designModal) return
try {
if (designModal.mode === 'create') {
const created = STANDALONE
? standaloneStorage.createDesign(data.name, data.icon)
: (await designsApi.create({ name: data.name, icon: data.icon })).data
let created: Design
if (data.sourceId) {
// Copy from an existing canvas (nodes, edges, viewport, floor plan).
created = STANDALONE
? standaloneStorage.copyDesign(data.sourceId, data.name, data.icon)
: (await designsApi.copy(data.sourceId, { name: data.name, icon: data.icon })).data
} else {
created = STANDALONE
? standaloneStorage.createDesign(data.name, data.icon)
: (await designsApi.create({ name: data.name, icon: data.icon })).data
}
addDesign(created)
} else if (designModal.design) {
const updated = STANDALONE
@@ -306,6 +314,11 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
showFloorMap={!STANDALONE && isActiveEdit}
initialFloorMap={!STANDALONE && isActiveEdit ? floorMap : null}
onUploadImage={handleUploadImage}
sourceDesigns={
designModal?.mode === 'create'
? (STANDALONE ? standaloneStorage.listDesignsWithCounts() : designs)
: []
}
/>
</aside>
)
+4
View File
@@ -8,6 +8,10 @@ export interface Design {
icon?: string | null
created_at: string
updated_at: string
/** Populated by the design list endpoint for the "copy from existing" picker. */
node_count?: number | null
group_count?: number | null
text_count?: number | null
}
export type NodeType =
@@ -16,6 +16,7 @@ import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import { useCanvasStore } from '@/stores/canvasStore'
import { demoNodes, demoEdges } from '@/utils/demoData'
import * as standaloneStorage from '@/utils/standaloneStorage'
const STORAGE_KEY = 'homelable_canvas'
@@ -261,3 +262,75 @@ describe('Demo data (standalone fallback)', () => {
expect(router.position).toEqual({ x: 300, y: 140 })
})
})
describe('Standalone copy-from-existing', () => {
beforeEach(() => localStorage.clear())
function typedNode(id: string, type: string): Node<NodeData> {
return {
id,
type,
position: { x: 0, y: 0 },
data: { label: id, type: type as NodeData['type'], status: 'unknown', services: [] },
}
}
it('designCounts buckets node / group / text types', () => {
const d = standaloneStorage.createDesign('Src')
standaloneStorage.saveCanvas(d.id, {
nodes: [typedNode('a', 'server'), typedNode('g', 'groupRect'), typedNode('t', 'text'), typedNode('b', 'router')],
edges: [],
})
expect(standaloneStorage.designCounts(d.id)).toEqual({ node_count: 2, group_count: 1, text_count: 1 })
})
it('designCounts returns zeros for a never-saved design', () => {
const d = standaloneStorage.createDesign('Empty')
expect(standaloneStorage.designCounts(d.id)).toEqual({ node_count: 0, group_count: 0, text_count: 0 })
})
it('listDesignsWithCounts attaches counts to every design', () => {
const a = standaloneStorage.createDesign('A')
standaloneStorage.saveCanvas(a.id, { nodes: [typedNode('a', 'server')], edges: [] })
standaloneStorage.createDesign('B')
const listed = standaloneStorage.listDesignsWithCounts()
expect(listed.find((x) => x.id === a.id)?.node_count).toBe(1)
expect(listed.find((x) => x.name === 'B')?.node_count).toBe(0)
})
it('copyDesign clones the source canvas into a new design', () => {
const src = standaloneStorage.createDesign('Source', 'server')
const edge: Edge<EdgeData> = { id: 'e1', source: 'a', target: 'b', data: { type: 'ethernet' } as EdgeData }
standaloneStorage.saveCanvas(src.id, { nodes: [typedNode('a', 'server'), typedNode('b', 'router')], edges: [edge] })
const copy = standaloneStorage.copyDesign(src.id, 'Copy', 'network')
expect(copy.id).not.toBe(src.id)
expect(copy.name).toBe('Copy')
const copied = standaloneStorage.loadCanvas(copy.id)!
expect(copied.nodes.map((n) => n.id).sort()).toEqual(['a', 'b'])
expect(copied.edges).toHaveLength(1)
// Source untouched.
expect(standaloneStorage.loadCanvas(src.id)!.nodes).toHaveLength(2)
})
it('copyDesign detaches the copy (mutating it leaves the source intact)', () => {
const src = standaloneStorage.createDesign('Source')
standaloneStorage.saveCanvas(src.id, { nodes: [typedNode('a', 'server')], edges: [] })
const copy = standaloneStorage.copyDesign(src.id, 'Copy')
const copied = standaloneStorage.loadCanvas(copy.id)!
copied.nodes.push(typedNode('z', 'server'))
standaloneStorage.saveCanvas(copy.id, copied)
expect(standaloneStorage.loadCanvas(src.id)!.nodes).toHaveLength(1)
expect(standaloneStorage.loadCanvas(copy.id)!.nodes).toHaveLength(2)
})
it('copyDesign on a never-saved source yields an empty new design', () => {
const src = standaloneStorage.createDesign('Bare')
const copy = standaloneStorage.copyDesign(src.id, 'Copy')
expect(standaloneStorage.loadCanvas(copy.id)).toBeNull()
expect(standaloneStorage.listDesigns().some((d) => d.id === copy.id)).toBe(true)
})
})
+35
View File
@@ -90,6 +90,41 @@ export function createDesign(name: string, icon?: string | null, design_type: De
return design
}
const GROUP_TYPE = 'groupRect'
const TEXT_TYPE = 'text'
/** Node/group/text counts for a design's saved canvas (0s when never saved). */
export function designCounts(designId: string): Pick<Design, 'node_count' | 'group_count' | 'text_count'> {
const canvas = loadCanvas(designId)
const nodes = canvas?.nodes ?? []
let group = 0
let text = 0
let node = 0
for (const n of nodes) {
if (n.data?.type === GROUP_TYPE) group++
else if (n.data?.type === TEXT_TYPE) text++
else node++
}
return { node_count: node, group_count: group, text_count: text }
}
/** Return the design list with per-canvas counts filled in (for the copy picker). */
export function listDesignsWithCounts(): Design[] {
return listDesigns().map((d) => ({ ...d, ...designCounts(d.id) }))
}
/** Deep-copy a design's canvas into a new design. Returns the new design. */
export function copyDesign(sourceId: string, name: string, icon?: string | null): Design {
const design = createDesign(name, icon)
const source = loadCanvas(sourceId)
if (source) {
// localStorage canvas already stores React Flow nodes/edges by value; a fresh
// JSON round-trip is enough to detach the copy from the source.
saveCanvas(design.id, JSON.parse(JSON.stringify(source)) as StandaloneCanvas)
}
return design
}
export function updateDesign(id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>): Design | null {
const designs = listDesigns()
const idx = designs.findIndex((d) => d.id === id)