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
+101 -3
View File
@@ -1,14 +1,21 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
router = APIRouter() router = APIRouter()
# Node.type values that are canvas annotations rather than real devices. Kept in
# sync with the frontend (Sidebar counts, canvasSerializer types).
_GROUP_TYPE = "groupRect"
_TEXT_TYPE = "text"
@router.get("", response_model=list[DesignResponse]) @router.get("", response_model=list[DesignResponse])
async def list_designs( async def list_designs(
@@ -16,7 +23,31 @@ async def list_designs(
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> list[DesignResponse]: ) -> list[DesignResponse]:
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all() designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
return [DesignResponse.model_validate(d) for d in designs] # One grouped query for all designs → node/group/text counts per design.
rows = (
await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type))
).all()
counts: dict[str, dict[str, int]] = {}
for design_id, node_type, count in rows:
if design_id is None:
continue
bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0})
if node_type == _GROUP_TYPE:
bucket["group"] += count
elif node_type == _TEXT_TYPE:
bucket["text"] += count
else:
bucket["node"] += count
result = []
for d in designs:
resp = DesignResponse.model_validate(d)
c = counts.get(d.id, {"node": 0, "group": 0, "text": 0})
resp.node_count = c["node"]
resp.group_count = c["group"]
resp.text_count = c["text"]
result.append(resp)
return result
@router.post("", response_model=DesignResponse, status_code=201) @router.post("", response_model=DesignResponse, status_code=201)
@@ -35,6 +66,73 @@ async def create_design(
return DesignResponse.model_validate(design) return DesignResponse.model_validate(design)
@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201)
async def copy_design(
source_id: str,
body: DesignCopy,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
"""Create a new design that deep-copies the source's nodes, edges and canvas state."""
source = await db.get(Design, source_id)
if not source:
raise HTTPException(404, "Source design not found")
new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type)
db.add(new_design)
await db.flush()
src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all()
src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all()
# New id per source node so edges and parent links can be re-pointed at the copy.
id_map = {n.id: str(uuid.uuid4()) for n in src_nodes}
# Columns we set explicitly or let the DB default — never copy verbatim.
node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"}
for n in src_nodes:
cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip}
db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols))
await db.flush() # nodes must exist before we wire self-referential parent_id
# Second pass: re-point parent links inside the copy.
for n in src_nodes:
if n.parent_id and n.parent_id in id_map:
child = await db.get(Node, id_map[n.id])
if child:
child.parent_id = id_map[n.parent_id]
edge_skip = {"id", "design_id", "source", "target", "created_at"}
for e in src_edges:
# Skip edges whose endpoints aren't part of this design (dangling FKs).
if e.source not in id_map or e.target not in id_map:
continue
cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip}
db.add(
Edge(
id=str(uuid.uuid4()),
design_id=new_design.id,
source=id_map[e.source],
target=id_map[e.target],
**cols,
)
)
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
src_state = await db.get(CanvasState, source_id)
db.add(
CanvasState(
design_id=new_design.id,
viewport=src_state.viewport if src_state else {},
custom_style=src_state.custom_style if src_state else None,
)
)
await db.commit()
await db.refresh(new_design)
return DesignResponse.model_validate(new_design)
@router.put("/{design_id}", response_model=DesignResponse) @router.put("/{design_id}", response_model=DesignResponse)
async def update_design( async def update_design(
design_id: str, design_id: str,
+12
View File
@@ -16,6 +16,13 @@ class DesignUpdate(BaseModel):
icon: str | None = None icon: str | None = None
class DesignCopy(BaseModel):
"""Create a new design by deep-copying an existing one's canvas."""
name: str
icon: str = "dashboard"
class DesignResponse(BaseModel): class DesignResponse(BaseModel):
id: str id: str
name: str name: str
@@ -23,5 +30,10 @@ class DesignResponse(BaseModel):
icon: str | None = None icon: str | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
# Populated by list_designs so the "copy from existing" picker can show what
# each canvas holds. None on create/update/copy responses (not computed there).
node_count: int | None = None
group_count: int | None = None
text_count: int | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+112
View File
@@ -100,6 +100,118 @@ async def test_list_returns_created_designs_ordered(client: AsyncClient, headers
assert ids == [a["id"], b["id"]] assert ids == [a["id"], b["id"]]
# ── counts in list ──────────────────────────────────────────────────────────
async def test_list_includes_node_group_text_counts(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Counted")
server = node_payload(label="S", type="server")
group = node_payload(label="G", type="groupRect")
text = node_payload(label="T", type="text")
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [server, group, text], "edges": [], "viewport": {}, "design_id": design["id"]},
headers=headers,
)
assert save.status_code == 200
listed = (await client.get("/api/v1/designs", headers=headers)).json()
d = next(x for x in listed if x["id"] == design["id"])
assert d["node_count"] == 1
assert d["group_count"] == 1
assert d["text_count"] == 1
async def test_list_counts_zero_for_empty_design(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Empty")
listed = (await client.get("/api/v1/designs", headers=headers)).json()
d = next(x for x in listed if x["id"] == design["id"])
assert d["node_count"] == 0
assert d["group_count"] == 0
assert d["text_count"] == 0
# ── copy ──────────────────────────────────────────────────────────────────────
async def test_copy_requires_auth(client: AsyncClient):
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"})
assert res.status_code == 401
async def test_copy_missing_source_returns_404(client: AsyncClient, headers: dict):
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}, headers=headers)
assert res.status_code == 404
async def test_copy_duplicates_nodes_edges_and_remaps_ids(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Source", icon="server")
n1 = node_payload(label="A")
n2 = node_payload(label="B")
e1 = edge_payload(n1["id"], n2["id"], label="link")
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 5, "y": 6, "zoom": 2}, "design_id": source["id"]},
headers=headers,
)
assert save.status_code == 200
res = await client.post(
f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy", "icon": "network"}, headers=headers,
)
assert res.status_code == 201, res.text
copy = res.json()
assert copy["name"] == "Copy"
assert copy["icon"] == "network"
assert copy["design_type"] == "network"
assert copy["id"] != source["id"]
# Copied canvas has the same shape but fresh node ids, and edge re-pointed.
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
assert {n["label"] for n in canvas["nodes"]} == {"A", "B"}
copied_ids = {n["id"] for n in canvas["nodes"]}
assert copied_ids.isdisjoint({n1["id"], n2["id"]})
assert len(canvas["edges"]) == 1
edge = canvas["edges"][0]
assert edge["source"] in copied_ids
assert edge["target"] in copied_ids
assert edge["label"] == "link"
assert canvas["viewport"] == {"x": 5, "y": 6, "zoom": 2}
async def test_copy_remaps_parent_child_relationship(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Nested")
parent = node_payload(label="P", type="proxmox", container_mode=True)
child = node_payload(label="C", type="vm", parent_id=parent["id"])
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [parent, child], "edges": [], "viewport": {}, "design_id": source["id"]},
headers=headers,
)
assert save.status_code == 200
res = await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
assert res.status_code == 201
copy = res.json()
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
by_label = {n["label"]: n for n in canvas["nodes"]}
# Child's parent_id points at the COPIED parent, not the original.
assert by_label["C"]["parent_id"] == by_label["P"]["id"]
assert by_label["C"]["parent_id"] != parent["id"]
async def test_copy_leaves_source_untouched(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Source")
n1 = node_payload(label="A")
await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1], "edges": [], "viewport": {}, "design_id": source["id"]},
headers=headers,
)
await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
src_canvas = (await client.get("/api/v1/canvas", params={"design_id": source["id"]}, headers=headers)).json()
assert len(src_canvas["nodes"]) == 1
assert src_canvas["nodes"][0]["id"] == n1["id"]
# ── update ──────────────────────────────────────────────────────────────────── # ── update ────────────────────────────────────────────────────────────────────
async def test_update_design_renames(client: AsyncClient, headers: dict): async def test_update_design_renames(client: AsyncClient, headers: dict):
+2
View File
@@ -169,6 +169,8 @@ export const designsApi = {
list: () => api.get<import('@/types').Design[]>('/designs'), list: () => api.get<import('@/types').Design[]>('/designs'),
create: (data: { name: string; icon?: string; design_type?: string }) => create: (data: { name: string; icon?: string; design_type?: string }) =>
api.post<import('@/types').Design>('/designs', data), 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 }) => update: (id: string, data: { name?: string; icon?: string }) =>
api.put<import('@/types').Design>(`/designs/${id}`, data), api.put<import('@/types').Design>(`/designs/${id}`, data),
delete: (id: string) => api.delete(`/designs/${id}`), 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 { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
import type { FloorMapConfig } from '@/types' import type { Design, FloorMapConfig } from '@/types'
export interface DesignFormData { export interface DesignFormData {
name: string name: string
@@ -15,6 +15,11 @@ export interface DesignFormData {
* plan"; `undefined` means "leave it untouched". * plan"; `undefined` means "leave it untouched".
*/ */
floorMap?: FloorMapConfig | null 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 { interface DesignModalProps {
@@ -34,6 +39,11 @@ interface DesignModalProps {
* base64. Rejects on failure (caller surfaces the error). * base64. Rejects on failure (caller surfaces the error).
*/ */
onUploadImage?: (file: File) => Promise<string> 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({ export function DesignModal({
@@ -46,10 +56,16 @@ export function DesignModal({
showFloorMap = false, showFloorMap = false,
initialFloorMap = null, initialFloorMap = null,
onUploadImage, onUploadImage,
sourceDesigns = [],
}: DesignModalProps) { }: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '') const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON) 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) // Floor plan state (only used when showFloorMap)
const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '') const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '')
const [width, setWidth] = useState(initialFloorMap?.width ?? 800) const [width, setWidth] = useState(initialFloorMap?.width ?? 800)
@@ -85,7 +101,11 @@ export function DesignModal({
const handleSubmit = () => { const handleSubmit = () => {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return if (!trimmed) return
if (canCopy && fromExisting && !sourceId) return
const data: DesignFormData = { name: trimmed, icon } const data: DesignFormData = { name: trimmed, icon }
if (canCopy && fromExisting && sourceId) {
data.sourceId = sourceId
}
if (showFloorMap) { if (showFloorMap) {
data.floorMap = imageData data.floorMap = imageData
? { ? {
@@ -153,6 +173,68 @@ export function DesignModal({
</div> </div>
</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 && ( {showFloorMap && (
<div className="space-y-2 pt-2 border-t border-border"> <div className="space-y-2 pt-2 border-t border-border">
<Label>Floor Plan</Label> <Label>Floor Plan</Label>
@@ -65,6 +65,60 @@ describe('DesignModal', () => {
expect(onSubmit).not.toHaveBeenCalled() 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', () => { describe('floor plan section', () => {
const fm = { const fm = {
imageData: 'data:image/png;base64,abc', 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 if (!designModal) return
try { try {
if (designModal.mode === 'create') { if (designModal.mode === 'create') {
const created = STANDALONE let created: Design
? standaloneStorage.createDesign(data.name, data.icon) if (data.sourceId) {
: (await designsApi.create({ name: data.name, icon: data.icon })).data // 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) addDesign(created)
} else if (designModal.design) { } else if (designModal.design) {
const updated = STANDALONE const updated = STANDALONE
@@ -306,6 +314,11 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
showFloorMap={!STANDALONE && isActiveEdit} showFloorMap={!STANDALONE && isActiveEdit}
initialFloorMap={!STANDALONE && isActiveEdit ? floorMap : null} initialFloorMap={!STANDALONE && isActiveEdit ? floorMap : null}
onUploadImage={handleUploadImage} onUploadImage={handleUploadImage}
sourceDesigns={
designModal?.mode === 'create'
? (STANDALONE ? standaloneStorage.listDesignsWithCounts() : designs)
: []
}
/> />
</aside> </aside>
) )
+4
View File
@@ -8,6 +8,10 @@ export interface Design {
icon?: string | null icon?: string | null
created_at: string created_at: string
updated_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 = export type NodeType =
@@ -16,6 +16,7 @@ import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import * as standaloneStorage from '@/utils/standaloneStorage'
const STORAGE_KEY = 'homelable_canvas' const STORAGE_KEY = 'homelable_canvas'
@@ -261,3 +262,75 @@ describe('Demo data (standalone fallback)', () => {
expect(router.position).toEqual({ x: 300, y: 140 }) 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 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 { export function updateDesign(id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>): Design | null {
const designs = listDesigns() const designs = listDesigns()
const idx = designs.findIndex((d) => d.id === id) const idx = designs.findIndex((d) => d.id === id)