feat: manage canvases with custom name and icon
Make designs (canvases) fully user-manageable: create with a chosen name and icon, rename, change icon, and delete. Replaces the hardcoded "New Electrical Design" button with a generic "New Canvas" flow. - Add Design.icon column + migration that backfills legacy rows (electrical -> zap, others -> dashboard) - DesignModal: name input + curated lucide icon picker (create + edit) - Sidebar switcher gains per-canvas edit/delete; delete guards the last canvas and confirms - designStore: addDesign/updateDesign/removeDesign with active reassignment - Fix data loss on design switch: abort load when the save fails and keep unsaved edits; skip the save-old step when the previous canvas was deleted - designsApi create/update carry icon; design_type kept for back-compat Tests: backend design CRUD (icon + cascade + last-canvas guard), designStore actions, designIcons resolver, DesignModal create/edit/validation. ha-relevant: yes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user