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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user