From 2a9cbc593253e0cfc73e7d0c63c22a02b84e11ea Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 28 Mar 2026 11:57:09 +0100 Subject: [PATCH] feat: resizable nodes with width/height persistence Add NodeResizer to BaseNode so users can drag corners to resize any node. Persist width/height through the full stack: DB model, schemas, canvas save/load route, and migration for existing databases. Add tests covering save, update, clear, and load of node dimensions. --- backend/app/db/database.py | 4 ++ backend/app/db/models.py | 2 + backend/app/schemas/canvas.py | 2 + backend/app/schemas/nodes.py | 4 ++ backend/tests/test_canvas.py | 46 +++++++++++++++++++ .../src/components/canvas/nodes/BaseNode.tsx | 15 ++++-- .../src/stores/__tests__/canvasStore.test.ts | 36 +++++++++++++++ 7 files changed, 106 insertions(+), 3 deletions(-) diff --git a/backend/app/db/database.py b/backend/app/db/database.py index a2a57a5..40fd629 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -52,6 +52,10 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL") with suppress(Exception): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0") + with suppress(Exception): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") + with suppress(Exception): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") async def get_db() -> AsyncGenerator[AsyncSession, None]: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 8438e16..2654f8a 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -42,6 +42,8 @@ class Node(Base): ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True) disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True) show_hardware: Mapped[bool] = mapped_column(Boolean, default=False) + width: Mapped[float | None] = mapped_column(Float, nullable=True) + height: Mapped[float | None] = mapped_column(Float, nullable=True) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 348c19e..9c768d4 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -28,6 +28,8 @@ class NodeSave(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool = False + width: float | None = None + height: float | None = None pos_x: float = 0 pos_y: float = 0 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index afe7f2e..caaad34 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -27,6 +27,8 @@ class NodeBase(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool = False + width: float | None = None + height: float | None = None class NodeCreate(NodeBase): @@ -56,6 +58,8 @@ class NodeUpdate(BaseModel): ram_gb: float | None = None disk_gb: float | None = None show_hardware: bool | None = None + width: float | None = None + height: float | None = None class NodeResponse(NodeBase): diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 8519923..8187e06 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -191,3 +191,49 @@ async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient node = canvas["nodes"][0] assert node["cpu_count"] is None assert node["ram_gb"] is None + + +# ── node width / height (resizable nodes) ───────────────────────────────────── + +async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict): + n1 = node_payload(width=320.0, height=180.0) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node = canvas["nodes"][0] + assert node["width"] == 320.0 + assert node["height"] == 180.0 + + +async def test_save_canvas_dimensions_default_null(client: AsyncClient, headers: dict): + n1 = node_payload() + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["width"] is None + assert canvas["nodes"][0]["height"] is None + + +async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict): + n1 = node_payload(width=140.0, height=50.0) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + n1_resized = {**n1, "width": 280.0, "height": 120.0} + await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + node = canvas["nodes"][0] + assert node["width"] == 280.0 + assert node["height"] == 120.0 + + +async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict): + n1 = node_payload(width=300.0, height=200.0) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + n1_cleared = {**n1, "width": None, "height": None} + await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["width"] is None + assert canvas["nodes"][0]["height"] is None diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 28fc616..35ee327 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,5 +1,5 @@ import { createElement } from 'react' -import { Handle, Position, type NodeProps, type Node } from '@xyflow/react' +import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' @@ -18,7 +18,7 @@ function formatStorage(gb: number): string { return `${gb} GB` } -export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { +export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) { const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) const theme = THEMES[activeTheme] @@ -43,8 +43,17 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { : 'none', opacity: data.status === 'offline' ? 0.55 : 1, minWidth: 140, + width: width ? '100%' : undefined, + height: height ? '100%' : undefined, }} > +
diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 6c508bb..f824ed7 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -328,4 +328,40 @@ describe('canvasStore', () => { useCanvasStore.getState().pasteNodes() expect(useCanvasStore.getState().nodes).toHaveLength(1) }) + + // --- Node resizing (width / height) --- + + it('addNode preserves explicit width and height', () => { + const node: Node = { ...makeNode('n1'), width: 280, height: 120 } + useCanvasStore.getState().addNode(node) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(stored?.width).toBe(280) + expect(stored?.height).toBe(120) + }) + + it('onNodesChange dimensions change updates width and height', () => { + useCanvasStore.getState().addNode(makeNode('n1')) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().onNodesChange([ + { type: 'dimensions', id: 'n1', dimensions: { width: 320, height: 180 }, resizing: true }, + ]) + const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(node?.measured?.width ?? node?.width).toBeDefined() + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('loadCanvas preserves width and height on resized nodes', () => { + const resized: Node = { ...makeNode('n1'), width: 300, height: 160 } + useCanvasStore.getState().loadCanvas([resized], []) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(stored?.width).toBe(300) + expect(stored?.height).toBe(160) + }) + + it('loadCanvas preserves undefined width/height for default-sized nodes', () => { + useCanvasStore.getState().loadCanvas([makeNode('n1')], []) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(stored?.width).toBeUndefined() + expect(stored?.height).toBeUndefined() + }) })