Merge pull request #257 from Pouzor/feat/canvas-copy-from-existing
feat: create a new canvas by copying an existing one
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
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)
|
? standaloneStorage.createDesign(data.name, data.icon)
|
||||||
: (await designsApi.create({ name: data.name, icon: data.icon })).data
|
: (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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user