diff --git a/backend/app/api/routes/designs.py b/backend/app/api/routes/designs.py index 07edad6..bed6d9e 100644 --- a/backend/app/api/routes/designs.py +++ b/backend/app/api/routes/designs.py @@ -25,7 +25,7 @@ async def create_design( db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> DesignResponse: - design = Design(name=body.name, design_type=body.design_type) + design = Design(name=body.name, design_type=body.design_type, icon=body.icon) db.add(design) await db.flush() # Create empty canvas state for the new design @@ -47,6 +47,8 @@ async def update_design( raise HTTPException(404, "Design not found") if body.name is not None: design.name = body.name + if body.icon is not None: + design.icon = body.icon await db.commit() await db.refresh(design) return DesignResponse.model_validate(design) diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 03a09a8..3da2212 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -183,6 +183,19 @@ async def init_db() -> None: ")", label="designs.table", ) + # Add user-chosen icon to designs (idempotent), then backfill existing rows + # so legacy designs keep a sensible icon based on their original type. + await _try_migrate( + conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon", + ) + with suppress(OperationalError): + await conn.exec_driver_sql( + "UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'" + ) + with suppress(OperationalError): + await conn.exec_driver_sql( + "UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL" + ) # Seed default Network Topology design if designs table is empty _default_design_id = str(_uuid_mod.uuid4()) row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs") @@ -190,8 +203,8 @@ async def init_db() -> None: count = count_row[0] if count_row else 0 if count == 0: await conn.exec_driver_sql( - "INSERT INTO designs (id, name, design_type, created_at, updated_at) " - "VALUES (?, 'Network Topology', 'network', datetime('now'), datetime('now'))", + "INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) " + "VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))", (_default_design_id,), ) else: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index e74b7fa..caccdc0 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -22,6 +22,7 @@ class Design(Base): id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String, nullable=False) design_type: Mapped[str] = mapped_column(String, nullable=False, default="network") + icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) diff --git a/backend/app/schemas/designs.py b/backend/app/schemas/designs.py index 92b67ab..32ae107 100644 --- a/backend/app/schemas/designs.py +++ b/backend/app/schemas/designs.py @@ -5,17 +5,22 @@ from pydantic import BaseModel class DesignCreate(BaseModel): name: str - design_type: str = "electrical" + icon: str = "dashboard" + # Vestigial: kept for backward compatibility. The UI no longer branches on it; + # the chosen icon now drives presentation. Defaults to a generic canvas. + design_type: str = "network" class DesignUpdate(BaseModel): name: str | None = None + icon: str | None = None class DesignResponse(BaseModel): id: str name: str design_type: str + icon: str | None = None created_at: datetime updated_at: datetime diff --git a/backend/tests/test_designs.py b/backend/tests/test_designs.py new file mode 100644 index 0000000..f70dbeb --- /dev/null +++ b/backend/tests/test_designs.py @@ -0,0 +1,165 @@ +import uuid + +import pytest +from httpx import AsyncClient + + +@pytest.fixture +async def headers(client: AsyncClient): + res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}) + return {"Authorization": f"Bearer {res.json()['access_token']}"} + + +def node_payload(**kwargs): + return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs} + + +def edge_payload(src, tgt, **kwargs): + return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs} + + +async def _create(client: AsyncClient, headers: dict, **body) -> dict: + res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers) + assert res.status_code == 201, res.text + return res.json() + + +# ── auth ────────────────────────────────────────────────────────────────────── + +async def test_list_designs_requires_auth(client: AsyncClient): + res = await client.get("/api/v1/designs") + assert res.status_code == 401 + + +async def test_create_design_requires_auth(client: AsyncClient): + res = await client.post("/api/v1/designs", json={"name": "X"}) + assert res.status_code == 401 + + +# ── list / create ───────────────────────────────────────────────────────────── + +async def test_list_designs_empty(client: AsyncClient, headers: dict): + res = await client.get("/api/v1/designs", headers=headers) + assert res.status_code == 200 + assert res.json() == [] + + +async def test_create_design_defaults(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Workshop") + assert design["name"] == "Workshop" + assert design["design_type"] == "network" + assert design["icon"] == "dashboard" + assert "id" in design and design["id"] + + +async def test_create_design_explicit_type(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Net", design_type="network") + assert design["design_type"] == "network" + + +async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Power", icon="zap") + assert design["icon"] == "zap" + + +async def test_update_design_changes_icon(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="D", icon="dashboard") + res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers) + assert res.status_code == 200 + assert res.json()["icon"] == "server" + # Name left untouched when only icon is sent. + assert res.json()["name"] == "D" + + +async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Old", icon="dashboard") + res = await client.put( + f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers, + ) + assert res.status_code == 200 + body = res.json() + assert body["name"] == "New" + assert body["icon"] == "network" + + +async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Has Canvas") + # Loading the new design returns an (empty) canvas without falling back to another design. + res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers) + assert res.status_code == 200 + body = res.json() + assert body["nodes"] == [] + assert body["edges"] == [] + + +async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict): + a = await _create(client, headers, name="First") + b = await _create(client, headers, name="Second") + listed = (await client.get("/api/v1/designs", headers=headers)).json() + ids = [d["id"] for d in listed] + assert ids == [a["id"], b["id"]] + + +# ── update ──────────────────────────────────────────────────────────────────── + +async def test_update_design_renames(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Old Name") + res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers) + assert res.status_code == 200 + assert res.json()["name"] == "New Name" + + +async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict): + res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers) + assert res.status_code == 404 + + +# ── delete ──────────────────────────────────────────────────────────────────── + +async def test_delete_last_design_blocked(client: AsyncClient, headers: dict): + design = await _create(client, headers, name="Only One") + res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers) + assert res.status_code == 400 + + +async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict): + # Need >1 design so we get past nothing; 404 path is checked before the count guard. + await _create(client, headers, name="Keep") + res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers) + assert res.status_code == 404 + + +async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict): + keep = await _create(client, headers, name="Keep") + victim = await _create(client, headers, name="Victim") + + # Populate the victim design with nodes + an edge via canvas save. + n1 = node_payload(label="A") + n2 = node_payload(label="B") + e1 = edge_payload(n1["id"], n2["id"]) + save = await client.post( + "/api/v1/canvas/save", + json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]}, + headers=headers, + ) + assert save.status_code == 200 + + # Populate the kept design too, to prove scoping. + k1 = node_payload(label="K") + await client.post( + "/api/v1/canvas/save", + json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]}, + headers=headers, + ) + + res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers) + assert res.status_code == 204 + + # Victim gone from list. + listed = (await client.get("/api/v1/designs", headers=headers)).json() + assert [d["id"] for d in listed] == [keep["id"]] + + # Kept design's node survives untouched. + kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json() + assert len(kept_canvas["nodes"]) == 1 + assert kept_canvas["nodes"][0]["label"] == "K" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c885ee..7e4d800 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -72,23 +72,27 @@ export default function App() { const [exportModalOpen, setExportModalOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) - // Declare handleSave before the Ctrl+S effect so it is in scope - const handleSave = useCallback(async (designIdOverride?: string) => { + // Declare handleSave before the Ctrl+S effect so it is in scope. + // Returns true on success, false on failure — the design-switch effect relies + // on this to avoid loading (and clobbering) the canvas when a save fails. + const handleSave = useCallback(async (designIdOverride?: string): Promise => { try { const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) markSaved() toast.success('Canvas saved') - return + return true } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId }) markSaved() toast.success('Canvas saved') + return true } catch { toast.error('Save failed') + return false } }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId]) @@ -162,16 +166,37 @@ export default function App() { // Reload canvas when active design changes (after initial load) const initialLoadDone = useRef(false) const prevDesignRef = useRef(null) + // Set while we programmatically revert activeDesignId after a failed save, so + // the re-entrant effect run skips save/load and just re-syncs the refs. + const revertingRef = useRef(false) useEffect(() => { + if (revertingRef.current) { + revertingRef.current = false + prevDesignRef.current = activeDesignId + return + } if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) { const oldId = prevDesignRef.current - if (oldId && oldId !== activeDesignId) { + // If the previous design was deleted (no longer in the list), don't try to + // save into it — just load the newly-selected design. + const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false + if (oldId && oldId !== activeDesignId && oldStillExists) { // Save current (old) canvas data under the old design ID before switching. // We call handleSave directly (not via ref) so it runs in this effect's // closure where activeDesignId is already the NEW value — the override // ensures data is stored under the correct design_id. - handleSave(oldId).then(() => { - loadCanvasFromApi(activeDesignId) + const targetId = activeDesignId + handleSave(oldId).then((ok) => { + if (ok) { + loadCanvasFromApi(targetId) + } else { + // Save failed: don't load the new design — that would overwrite the + // unsaved in-memory canvas. Revert the selection back to the old + // design so the UI matches the data still on screen. + toast.error('Switch cancelled — unsaved changes kept') + revertingRef.current = true + setActiveDesign(oldId) + } }) } else { loadCanvasFromApi(activeDesignId) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ab63b1f..fb8eb34 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -95,9 +95,9 @@ export const settingsApi = { export const designsApi = { list: () => api.get('/designs'), - create: (data: { name: string; design_type: string }) => + create: (data: { name: string; icon?: string; design_type?: string }) => api.post('/designs', data), - update: (id: string, data: { name?: string }) => + update: (id: string, data: { name?: string; icon?: string }) => api.put(`/designs/${id}`, data), delete: (id: string) => api.delete(`/designs/${id}`), } diff --git a/frontend/src/components/modals/DesignModal.tsx b/frontend/src/components/modals/DesignModal.tsx new file mode 100644 index 0000000..d488470 --- /dev/null +++ b/frontend/src/components/modals/DesignModal.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +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' + +export interface DesignFormData { + name: string + icon: string +} + +interface DesignModalProps { + open: boolean + onClose: () => void + onSubmit: (data: DesignFormData) => void + initial?: DesignFormData + title?: string + submitLabel?: string +} + +export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) { + const [name, setName] = useState(initial?.name ?? '') + const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON) + + const handleSubmit = () => { + const trimmed = name.trim() + if (!trimmed) return + onSubmit({ name: trimmed, icon }) + } + + return ( + !o && onClose()}> + + + {title} + + +
+
+ + setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }} + placeholder="e.g. Home Network, Rack Power" + autoFocus + /> +
+ +
+ +
+ {DESIGN_ICONS.map((entry) => { + const Icon = entry.icon + const selected = entry.key === icon + return ( + + ) + })} +
+
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx new file mode 100644 index 0000000..681c668 --- /dev/null +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { DesignModal } from '../DesignModal' +import { DEFAULT_DESIGN_ICON } from '@/utils/designIcons' + +function renderModal(props: Partial[0]> = {}) { + const onClose = vi.fn() + const onSubmit = vi.fn() + render() + return { onClose, onSubmit } +} + +describe('DesignModal', () => { + it('creates with the typed name and default icon', () => { + const { onSubmit } = renderModal() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON }) + }) + + it('submits the selected icon', () => { + const { onSubmit } = renderModal() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } }) + fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' }) + }) + + it('trims whitespace and blocks empty names', () => { + const { onSubmit } = renderModal() + // Empty → submit disabled, no call. + const submit = screen.getByRole('button', { name: 'Create' }) + expect(submit).toBeDisabled() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } }) + fireEvent.click(submit) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON }) + }) + + it('prefills name and icon in edit mode', () => { + const { onSubmit } = renderModal({ + initial: { name: 'Existing', icon: 'server' }, + title: 'Edit Canvas', + submitLabel: 'Save', + }) + expect(screen.getByLabelText('Name')).toHaveValue('Existing') + // The server icon button is pre-selected. + expect(screen.getByRole('button', { name: 'Server' })).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Existing', icon: 'server' }) + }) + + it('submits on Enter from the name field', () => { + const { onSubmit } = renderModal() + const input = screen.getByLabelText('Name') + fireEvent.change(input, { target: { value: 'Quick' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON }) + }) + + it('calls onClose from Cancel', () => { + const { onClose, onSubmit } = renderModal() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index c0f1e0b..effa9cc 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,11 +1,14 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, Zap, PlusCircle } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { designsApi, scanApi, settingsApi } from '@/api/client' +import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' +import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' +import type { Design } from '@/types' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' import { @@ -51,9 +54,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee const [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) const logout = useAuthStore((s) => s.logout) - const { designs, activeDesignId, setActiveDesign } = useDesignStore() - const [creating, setCreating] = useState(false) + const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) + const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null) + + const handleDesignSubmit = useCallback(async (data: DesignFormData) => { + if (!designModal) return + try { + if (designModal.mode === 'create') { + const res = await designsApi.create({ name: data.name, icon: data.icon }) + addDesign(res.data) + } else if (designModal.design) { + const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon }) + updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon }) + } + setDesignModal(null) + } catch { + toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas') + } + }, [designModal, addDesign, updateDesign]) + + const handleDesignDelete = useCallback(async (d: Design) => { + if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } + if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return + try { + await designsApi.delete(d.id) + removeDesign(d.id) + toast.success('Canvas deleted') + } catch { + toast.error('Failed to delete canvas') + } + }, [designs.length, removeDesign]) // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. if (forceView !== prevForceView) { @@ -101,9 +132,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee > {activeDesignId ? (() => { const active = designs.find((d) => d.id === activeDesignId) - const Icon = active?.design_type === 'electrical' ? Zap : LayoutDashboard - return <>{active?.name ?? 'Select Design'} - })() : Select Design} + const Icon = resolveDesignIcon(active?.icon) + return <>{active?.name ?? 'Select Canvas'} + })() : Select Canvas} {designSwitcherOpen && ( <> @@ -111,40 +142,49 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
setDesignSwitcherOpen(false)} />
{designs.map((d) => { - const Icon = d.design_type === 'electrical' ? Zap : LayoutDashboard + const Icon = resolveDesignIcon(d.icon) + const isActive = d.id === activeDesignId return ( - + + + +
) })}
@@ -254,6 +294,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{!collapsed && } + + setDesignModal(null)} + onSubmit={handleDesignSubmit} + initial={designModal?.mode === 'edit' && designModal.design + ? { name: designModal.design.name, icon: designModal.design.icon ?? DEFAULT_DESIGN_ICON } + : undefined} + title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'} + submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'} + /> ) } diff --git a/frontend/src/stores/__tests__/designStore.test.ts b/frontend/src/stores/__tests__/designStore.test.ts new file mode 100644 index 0000000..c8368b5 --- /dev/null +++ b/frontend/src/stores/__tests__/designStore.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useDesignStore } from '@/stores/designStore' +import type { Design } from '@/types' + +function design(id: string, type: Design['design_type'] = 'network', name = id): Design { + return { id, name, design_type: type, created_at: '', updated_at: '' } +} + +describe('designStore', () => { + beforeEach(() => { + useDesignStore.setState({ designs: [], activeDesignId: null, activeDesignType: null, loaded: false }) + }) + + it('starts empty and not loaded', () => { + const s = useDesignStore.getState() + expect(s.designs).toEqual([]) + expect(s.activeDesignId).toBeNull() + expect(s.activeDesignType).toBeNull() + expect(s.loaded).toBe(false) + }) + + it('setDesigns selects the first design as active and marks loaded', () => { + const a = design('a', 'network') + const b = design('b', 'electrical') + useDesignStore.getState().setDesigns([a, b]) + const s = useDesignStore.getState() + expect(s.designs).toHaveLength(2) + expect(s.activeDesignId).toBe('a') + expect(s.activeDesignType).toBe('network') + expect(s.loaded).toBe(true) + }) + + it('setDesigns preserves the active design when it is still present', () => { + useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')]) + useDesignStore.getState().setActiveDesign('b') + // Re-list (e.g. after creating another design) — active id must not jump back to first. + useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical'), design('c')]) + const s = useDesignStore.getState() + expect(s.activeDesignId).toBe('b') + expect(s.activeDesignType).toBe('electrical') + }) + + it('setDesigns falls back to first when the active design was removed', () => { + useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')]) + useDesignStore.getState().setActiveDesign('b') + useDesignStore.getState().setDesigns([design('a')]) // 'b' deleted + const s = useDesignStore.getState() + expect(s.activeDesignId).toBe('a') + expect(s.activeDesignType).toBe('network') + }) + + it('setDesigns with an empty list clears the active selection', () => { + useDesignStore.getState().setDesigns([design('a')]) + useDesignStore.getState().setDesigns([]) + const s = useDesignStore.getState() + expect(s.activeDesignId).toBeNull() + expect(s.activeDesignType).toBeNull() + expect(s.loaded).toBe(true) + }) + + it('setActiveDesign updates id and resolves type', () => { + useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')]) + useDesignStore.getState().setActiveDesign('b') + const s = useDesignStore.getState() + expect(s.activeDesignId).toBe('b') + expect(s.activeDesignType).toBe('electrical') + }) + + it('setActiveDesign with an unknown id sets a null type', () => { + useDesignStore.getState().setDesigns([design('a')]) + useDesignStore.getState().setActiveDesign('missing') + const s = useDesignStore.getState() + expect(s.activeDesignId).toBe('missing') + expect(s.activeDesignType).toBeNull() + }) + + it('getActiveDesign returns the active design or null', () => { + expect(useDesignStore.getState().getActiveDesign()).toBeNull() + const b = design('b', 'electrical') + useDesignStore.getState().setDesigns([design('a'), b]) + useDesignStore.getState().setActiveDesign('b') + expect(useDesignStore.getState().getActiveDesign()).toEqual(b) + }) + + it('addDesign appends and makes the new design active', () => { + useDesignStore.getState().setDesigns([design('a')]) + const b = design('b', 'electrical', 'Power') + useDesignStore.getState().addDesign(b) + const s = useDesignStore.getState() + expect(s.designs.map((d) => d.id)).toEqual(['a', 'b']) + expect(s.activeDesignId).toBe('b') + expect(s.activeDesignType).toBe('electrical') + }) + + it('updateDesign patches name and icon in place without touching others', () => { + useDesignStore.getState().setDesigns([design('a'), design('b')]) + useDesignStore.getState().updateDesign('a', { name: 'Renamed', icon: 'server' }) + const designs = useDesignStore.getState().designs + expect(designs.find((d) => d.id === 'a')).toMatchObject({ name: 'Renamed', icon: 'server' }) + expect(designs.find((d) => d.id === 'b')!.name).toBe('b') + }) + + it('removeDesign drops a non-active design and keeps the active one', () => { + useDesignStore.getState().setDesigns([design('a'), design('b')]) + useDesignStore.getState().setActiveDesign('a') + useDesignStore.getState().removeDesign('b') + const s = useDesignStore.getState() + expect(s.designs.map((d) => d.id)).toEqual(['a']) + expect(s.activeDesignId).toBe('a') + }) + + it('removeDesign reassigns active to the first remaining when the active is removed', () => { + useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')]) + useDesignStore.getState().setActiveDesign('a') + useDesignStore.getState().removeDesign('a') + const s = useDesignStore.getState() + expect(s.designs.map((d) => d.id)).toEqual(['b']) + expect(s.activeDesignId).toBe('b') + expect(s.activeDesignType).toBe('electrical') + }) + + it('removeDesign clears active when the last design is removed', () => { + useDesignStore.getState().setDesigns([design('a')]) + useDesignStore.getState().setActiveDesign('a') + useDesignStore.getState().removeDesign('a') + const s = useDesignStore.getState() + expect(s.designs).toEqual([]) + expect(s.activeDesignId).toBeNull() + expect(s.activeDesignType).toBeNull() + }) +}) diff --git a/frontend/src/stores/designStore.ts b/frontend/src/stores/designStore.ts index faae460..2bce324 100644 --- a/frontend/src/stores/designStore.ts +++ b/frontend/src/stores/designStore.ts @@ -9,6 +9,12 @@ interface DesignState { setDesigns: (designs: Design[]) => void setActiveDesign: (id: string) => void getActiveDesign: () => Design | null + /** Append a new design and make it active. */ + addDesign: (design: Design) => void + /** Patch an existing design in place (name/icon edits). */ + updateDesign: (id: string, patch: Partial>) => void + /** Remove a design; if it was active, fall back to the first remaining one. */ + removeDesign: (id: string) => void } export const useDesignStore = create((set, get) => ({ @@ -39,4 +45,28 @@ export const useDesignStore = create((set, get) => ({ const { designs, activeDesignId } = get() return designs.find((d) => d.id === activeDesignId) ?? null }, + + addDesign: (design) => + set((state) => ({ + designs: [...state.designs, design], + activeDesignId: design.id, + activeDesignType: design.design_type, + })), + + updateDesign: (id, patch) => + set((state) => ({ + designs: state.designs.map((d) => (d.id === id ? { ...d, ...patch } : d)), + })), + + removeDesign: (id) => + set((state) => { + const designs = state.designs.filter((d) => d.id !== id) + if (state.activeDesignId !== id) return { designs } + const next = designs[0] ?? null + return { + designs, + activeDesignId: next?.id ?? null, + activeDesignType: next?.design_type ?? null, + } + }), })) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ed23a7f..db378e4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -4,6 +4,8 @@ export interface Design { id: string name: string design_type: DesignType + /** Lucide icon key (see utils/designIcons). User-chosen; may be null on legacy rows. */ + icon?: string | null created_at: string updated_at: string } diff --git a/frontend/src/utils/__tests__/designIcons.test.ts b/frontend/src/utils/__tests__/designIcons.test.ts new file mode 100644 index 0000000..554799b --- /dev/null +++ b/frontend/src/utils/__tests__/designIcons.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons' + +describe('designIcons', () => { + it('exposes a non-empty, unique-keyed icon set', () => { + expect(DESIGN_ICONS.length).toBeGreaterThan(0) + const keys = DESIGN_ICONS.map((e) => e.key) + expect(new Set(keys).size).toBe(keys.length) + }) + + it('default icon key is part of the set', () => { + expect(DESIGN_ICONS.some((e) => e.key === DEFAULT_DESIGN_ICON)).toBe(true) + }) + + it('resolveDesignIcon returns the matching component for a known key', () => { + const entry = DESIGN_ICONS.find((e) => e.key === 'zap')! + expect(resolveDesignIcon('zap')).toBe(entry.icon) + }) + + it('resolveDesignIcon falls back to a component for unknown/empty keys', () => { + const fallback = resolveDesignIcon(undefined) + expect(typeof fallback).toBe('object') + expect(resolveDesignIcon('does-not-exist')).toBe(fallback) + expect(resolveDesignIcon(null)).toBe(fallback) + expect(resolveDesignIcon('')).toBe(fallback) + }) +}) diff --git a/frontend/src/utils/designIcons.ts b/frontend/src/utils/designIcons.ts new file mode 100644 index 0000000..6762bee --- /dev/null +++ b/frontend/src/utils/designIcons.ts @@ -0,0 +1,44 @@ +import { + LayoutDashboard, Zap, Network, Server, HardDrive, Cpu, Wifi, Router, + Database, Cloud, Home, Globe, Lightbulb, Factory, Plug, Boxes, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +export interface DesignIconEntry { + key: string + label: string + icon: LucideIcon +} + +/** Curated icon set offered when creating/editing a canvas design. Keys are + * stable strings persisted on `Design.icon`. */ +export const DESIGN_ICONS: DesignIconEntry[] = [ + { key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { key: 'network', label: 'Network', icon: Network }, + { key: 'zap', label: 'Electrical', icon: Zap }, + { key: 'server', label: 'Server', icon: Server }, + { key: 'harddrive', label: 'Storage', icon: HardDrive }, + { key: 'cpu', label: 'Compute', icon: Cpu }, + { key: 'wifi', label: 'Wireless', icon: Wifi }, + { key: 'router', label: 'Router', icon: Router }, + { key: 'database', label: 'Database', icon: Database }, + { key: 'cloud', label: 'Cloud', icon: Cloud }, + { key: 'home', label: 'Home', icon: Home }, + { key: 'globe', label: 'Internet', icon: Globe }, + { key: 'lightbulb', label: 'Lighting', icon: Lightbulb }, + { key: 'factory', label: 'Industrial', icon: Factory }, + { key: 'plug', label: 'Power', icon: Plug }, + { key: 'boxes', label: 'Cluster', icon: Boxes }, +] + +export const DEFAULT_DESIGN_ICON = 'dashboard' + +const ICON_MAP: Record = Object.fromEntries( + DESIGN_ICONS.map((e) => [e.key, e.icon]), +) + +/** Resolve a persisted design icon key to a lucide component. Unknown/empty + * keys fall back to the dashboard icon so the UI never breaks on legacy data. */ +export function resolveDesignIcon(key?: string | null): LucideIcon { + return (key && ICON_MAP[key]) || LayoutDashboard +}