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.
This commit is contained in:
Pouzor
2026-03-28 11:57:09 +01:00
parent 09b5317a0c
commit 2a9cbc5932
7 changed files with 106 additions and 3 deletions
+4
View File
@@ -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]:
+2
View File
@@ -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)
+2
View File
@@ -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
+4
View File
@@ -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):
+46
View File
@@ -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