From 38a06682e53f008b0f49b3c7283b1533e9a4ac14 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 01:42:22 +0100 Subject: [PATCH 1/6] feat: rename Rectangle to Zone, add border width selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "Rectangle" → "Zone" in sidebar, add modal and edit modal - Add border width selector (1–5px, default 2px) to the Zone modal - Border width persisted in custom_colors.border_width and applied in GroupRectNode --- frontend/src/App.tsx | 10 ++-- .../components/canvas/nodes/GroupRectNode.tsx | 3 +- .../src/components/modals/GroupRectModal.tsx | 39 ++++++++++++++- .../modals/__tests__/GroupRectModal.test.tsx | 49 +++++++++++++++++-- frontend/src/components/panels/Sidebar.tsx | 4 +- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2530d25..6e58be9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -182,6 +182,7 @@ export default function App() { custom_colors: { border: data.border_color, border_style: data.border_style, + border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_position: data.text_position, @@ -198,6 +199,7 @@ export default function App() { const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { if (!editingGroupRectId) return + snapshotHistory() const existing = nodes.find((n) => n.id === editingGroupRectId) updateNode(editingGroupRectId, { label: data.label, @@ -205,6 +207,7 @@ export default function App() { ...existing?.data.custom_colors, border: data.border_color, border_style: data.border_style, + border_width: data.border_width, background: data.background_color, text_color: data.text_color, text_position: data.text_position, @@ -214,7 +217,7 @@ export default function App() { }) setNodeZIndex(editingGroupRectId, data.z_order - 10) setEditingGroupRectId(null) - }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId]) + }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory]) const handleDeleteGroupRect = useCallback(() => { if (!editingGroupRectId) return @@ -436,7 +439,7 @@ export default function App() { open={addGroupRectOpen} onClose={() => setAddGroupRectOpen(false)} onSubmit={handleAddGroupRect} - title="Add Rectangle" + title="Add Zone" /> {/* key forces re-mount when editing a different rect */} @@ -457,11 +460,12 @@ export default function App() { text_position: rc.text_position ?? 'top-left', border_color: rc.border ?? '#00d4ff', border_style: rc.border_style ?? 'solid', + border_width: rc.border_width ?? 2, background_color: rc.background ?? '#00d4ff0d', z_order: rc.z_order ?? 1, } })()} - title="Edit Rectangle" + title="Edit Zone" /> {/* key forces re-mount on open so useState captures current theme as original */} diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index bd6bfe9..45f2879 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -32,6 +32,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) const rc = data.custom_colors ?? {} const borderColor = rc.border ?? '#00d4ff' const borderStyle = rc.border_style ?? 'solid' + const borderWidth = rc.border_width ?? 2 const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const textColor = rc.text_color ?? '#e6edf3' const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter @@ -62,7 +63,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) justifyContent: posStyle.justifyContent, padding: 12, background: backgroundColor, - border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, + border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, borderRadius: 10, fontFamily, color: textColor, diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index ad1b490..b437b67 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -15,6 +15,7 @@ export interface GroupRectFormData { text_position: TextPosition border_color: string border_style: BorderStyle + border_width: number background_color: string z_order: number } @@ -27,6 +28,14 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = { value: 'none', label: 'None', preview: ' ' }, ] +const BORDER_WIDTHS: { value: number; label: string }[] = [ + { value: 1, label: '1px' }, + { value: 2, label: '2px' }, + { value: 3, label: '3px' }, + { value: 4, label: '4px' }, + { value: 5, label: '5px' }, +] + const DEFAULT_FORM: GroupRectFormData = { label: '', font: 'inter', @@ -34,6 +43,7 @@ const DEFAULT_FORM: GroupRectFormData = { text_position: 'top-left', border_color: '#00d4ff', border_style: 'solid', + border_width: 2, background_color: '#00d4ff0d', z_order: 1, } @@ -65,7 +75,7 @@ interface GroupRectModalProps { title?: string } -export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) { +export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) { const [form, setForm] = useState({ ...DEFAULT_FORM, ...initial }) const set = (key: K, value: GroupRectFormData[K]) => @@ -196,6 +206,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit + {/* Border width */} +
+ +
+ {BORDER_WIDTHS.map(({ value, label }) => { + const isSelected = form.border_width === value + return ( + + ) + })} +
+
+ {/* Z-order */}
@@ -230,7 +265,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit Cancel
diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx index 896b752..40f1528 100644 --- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -13,14 +13,15 @@ describe('GroupRectModal', () => { it('renders form fields when open', () => { render() expect(screen.getByPlaceholderText('Zone name…')).toBeDefined() - expect(screen.getByText('Add Rectangle')).toBeDefined() + expect(screen.getByText('Add Zone')).toBeDefined() expect(screen.getByText('Text Position')).toBeDefined() + expect(screen.getByText('Border Width')).toBeDefined() expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined() }) - it('renders Edit Rectangle title when provided', () => { - render() - expect(screen.getByText('Edit Rectangle')).toBeDefined() + it('renders Edit Zone title when provided', () => { + render() + expect(screen.getByText('Edit Zone')).toBeDefined() }) it('calls onSubmit with form data on submit', () => { @@ -123,6 +124,46 @@ describe('GroupRectModal', () => { expect(submitted.border_style).toBe('dotted') }) + it('renders Border Width section with 5 options', () => { + render() + expect(screen.getByText('Border Width')).toBeDefined() + expect(screen.getByText('1px')).toBeDefined() + expect(screen.getByText('3px')).toBeDefined() + expect(screen.getByText('5px')).toBeDefined() + }) + + it('defaults border_width to 2', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_width).toBe(2) + }) + + it('selects border width on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('4px')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_width).toBe(4) + }) + + it('pre-fills border_width from initial prop', () => { + const onSubmit = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_width).toBe(5) + }) + it('toggles border style — clicking selected style deselects back to solid', () => { const onSubmit = vi.fn() render() diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 90690c3..3105bdb 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -124,7 +124,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro {/* Actions */}
- + {!STANDALONE && } { load() }) + useEffect(() => { load() }, [load]) const handleIgnore = async (id: string) => { try { From 4ccdbed711f2f947ec0706e7e19ff5bffc5e3ffb Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 03:04:29 +0200 Subject: [PATCH 2/6] feat: add label position (inside/outside) and text size to Zone modal - Label position toggle: inside (default) or outside the border - Outside mode renders the label above/below the zone based on text_position - Text size selector: 10/12/14/16/18/20px (default 12) - Both fields persisted in custom_colors (no backend schema change needed) - 8 new frontend tests, 1 new backend test --- backend/tests/test_canvas.py | 19 +++++ frontend/src/App.tsx | 6 ++ .../components/canvas/nodes/GroupRectNode.tsx | 47 +++++++++-- .../src/components/modals/GroupRectModal.tsx | 71 +++++++++++++++++ .../modals/__tests__/GroupRectModal.test.tsx | 78 +++++++++++++++++++ 5 files changed, 215 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 8187e06..b4c6633 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -104,6 +104,25 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers: assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"} +async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict): + """label_position and text_size are stored in custom_colors and returned unchanged.""" + n1 = node_payload(custom_colors={ + "border": "#00d4ff", + "border_style": "solid", + "border_width": 3, + "label_position": "outside", + "text_size": 16, + "text_color": "#e6edf3", + }) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + cc = canvas["nodes"][0]["custom_colors"] + assert cc["label_position"] == "outside" + assert cc["text_size"] == 16 + assert cc["border_width"] == 3 + + async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict): n1 = node_payload() n2 = node_payload() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e58be9..b0da8db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -186,6 +186,8 @@ export default function App() { background: data.background_color, text_color: data.text_color, text_position: data.text_position, + text_size: data.text_size, + label_position: data.label_position, font: data.font, z_order: data.z_order, }, @@ -211,6 +213,8 @@ export default function App() { background: data.background_color, text_color: data.text_color, text_position: data.text_position, + text_size: data.text_size, + label_position: data.label_position, font: data.font, z_order: data.z_order, }, @@ -462,6 +466,8 @@ export default function App() { border_style: rc.border_style ?? 'solid', border_width: rc.border_width ?? 2, background_color: rc.background ?? '#00d4ff0d', + text_size: rc.text_size ?? 12, + label_position: rc.label_position ?? 'inside', z_order: rc.z_order ?? 1, } })()} diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index 45f2879..999eb95 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -35,10 +35,31 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) const borderWidth = rc.border_width ?? 2 const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const textColor = rc.text_color ?? '#e6edf3' + const textSize: number = rc.text_size ?? 12 + const labelPosition: string = rc.label_position ?? 'inside' const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter const textPos = (rc.text_position ?? 'top-left') as TextPosition const posStyle = POSITION_STYLES[textPos] + const outsideJustify = textPos.includes('right') ? 'flex-end' + : (textPos.includes('center') || textPos === 'center') ? 'center' + : 'flex-start' + + const isOutsideBottom = textPos.startsWith('bottom') + const outsideOffset = textSize + 16 + const outsideVertical: React.CSSProperties = isOutsideBottom + ? { bottom: -outsideOffset } + : { top: -outsideOffset } + + const sharedTextStyle: React.CSSProperties = { + color: textColor, + fontFamily, + fontSize: textSize, + fontWeight: 500, + userSelect: 'none', + whiteSpace: 'pre-wrap', + } + return ( <> >) />
>) background: backgroundColor, border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, borderRadius: 10, - fontFamily, - color: textColor, - fontSize: 12, - fontWeight: 500, boxSizing: 'border-box', cursor: 'default', }} @@ -77,8 +96,24 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) setEditingGroupRectId(id) }} > - {data.label && ( - + {labelPosition === 'outside' && data.label && ( + + {data.label} + + )} + {labelPosition === 'inside' && data.label && ( + {data.label} )} diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index b437b67..b9a4027 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -8,11 +8,15 @@ import type { TextPosition } from '@/types' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' +export type LabelPosition = 'inside' | 'outside' + export interface GroupRectFormData { label: string font: string text_color: string text_position: TextPosition + text_size: number + label_position: LabelPosition border_color: string border_style: BorderStyle border_width: number @@ -28,6 +32,20 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = { value: 'none', label: 'None', preview: ' ' }, ] +const TEXT_SIZES: { value: number; label: string }[] = [ + { value: 10, label: '10' }, + { value: 12, label: '12' }, + { value: 14, label: '14' }, + { value: 16, label: '16' }, + { value: 18, label: '18' }, + { value: 20, label: '20' }, +] + +const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [ + { value: 'inside', label: 'Inside' }, + { value: 'outside', label: 'Outside' }, +] + const BORDER_WIDTHS: { value: number; label: string }[] = [ { value: 1, label: '1px' }, { value: 2, label: '2px' }, @@ -41,6 +59,8 @@ const DEFAULT_FORM: GroupRectFormData = { font: 'inter', text_color: '#e6edf3', text_position: 'top-left', + text_size: 12, + label_position: 'inside', border_color: '#00d4ff', border_style: 'solid', border_width: 2, @@ -155,6 +175,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
+ {/* Label position */} +
+ +
+ {LABEL_POSITIONS.map(({ value, label }) => { + const isSelected = form.label_position === value + return ( + + ) + })} +
+
+ {/* Colors */}
@@ -179,6 +224,32 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
+ {/* Text size */} +
+ +
+ {TEXT_SIZES.map(({ value, label }) => { + const isSelected = form.text_size === value + return ( + + ) + })} +
+
+ {/* Border style */}
diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx index 40f1528..7c7233c 100644 --- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -124,6 +124,84 @@ describe('GroupRectModal', () => { expect(submitted.border_style).toBe('dotted') }) + it('renders Label Position section with inside/outside options', () => { + render() + expect(screen.getByText('Label Position')).toBeDefined() + expect(screen.getByText('Inside')).toBeDefined() + expect(screen.getByText('Outside')).toBeDefined() + }) + + it('defaults label_position to inside', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label_position).toBe('inside') + }) + + it('selects outside label position on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Outside')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label_position).toBe('outside') + }) + + it('pre-fills label_position from initial prop', () => { + const onSubmit = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label_position).toBe('outside') + }) + + it('renders Text Size section with 6 options', () => { + render() + expect(screen.getByText('Text Size')).toBeDefined() + expect(screen.getByText('10')).toBeDefined() + expect(screen.getByText('20')).toBeDefined() + }) + + it('defaults text_size to 12', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_size).toBe(12) + }) + + it('selects text size on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('18')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_size).toBe(18) + }) + + it('pre-fills text_size from initial prop', () => { + const onSubmit = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_size).toBe(16) + }) + it('renders Border Width section with 5 options', () => { render() expect(screen.getByText('Border Width')).toBeDefined() From 32b60a201b68728044ec44e019d33c61ad97c71f Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 14:29:33 +0200 Subject: [PATCH 3/6] fix: persist edge animation mode (None/Snake/Flow) end-to-end - canvasStore.onConnect: include animated in edge data object (was silently dropped) - Backend schemas: normalize animated bool/int to string ('none'/'snake'/'flow') via field_validator - ORM model: change animated column from Boolean to String - DB migration: convert existing 0/1 boolean rows to 'none'/'snake' strings --- backend/app/db/database.py | 37 ++++++++++-------- backend/app/db/models.py | 4 +- backend/app/schemas/canvas.py | 10 ++++- backend/app/schemas/edges.py | 20 ++++++++-- backend/app/schemas/utils.py | 9 +++++ .../src/stores/__tests__/canvasStore.test.ts | 38 +++++++++++++++++-- frontend/src/stores/canvasStore.ts | 35 +++++++++++------ 7 files changed, 115 insertions(+), 38 deletions(-) create mode 100644 backend/app/schemas/utils.py diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 40fd629..72016fe 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator from contextlib import suppress from pathlib import Path +from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -26,36 +27,42 @@ async def init_db() -> None: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Add columns introduced after initial schema (idempotent) - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") - with suppress(Exception): + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") + # Migrate animated column from boolean (0/1) to string ('none'/'snake') + with suppress(OperationalError): + await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") + with suppress(OperationalError): + sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL" + await conn.exec_driver_sql(sql) async def get_db() -> AsyncGenerator[AsyncSession, None]: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 2654f8a..1e2daf2 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -33,7 +33,7 @@ class Node(Base): notes: Mapped[str | None] = mapped_column(Text) pos_x: Mapped[float] = mapped_column(Float, default=0) pos_y: Mapped[float] = mapped_column(Float, default=0) - parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) + parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) container_mode: Mapped[bool] = mapped_column(Boolean, default=False) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_icon: Mapped[str | None] = mapped_column(String, nullable=True) @@ -65,7 +65,7 @@ class Edge(Base): speed: Mapped[str | None] = mapped_column(String) custom_color: Mapped[str | None] = mapped_column(String) path_style: Mapped[str | None] = mapped_column(String) - animated: Mapped[bool] = mapped_column(Boolean, default=False) + animated: Mapped[str] = mapped_column(String, nullable=False, default='none') source_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String) 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 9c768d4..e3bb87b 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -1,9 +1,10 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from app.schemas.edges import EdgeResponse from app.schemas.nodes import NodeResponse +from app.schemas.utils import normalize_animated class NodeSave(BaseModel): @@ -44,10 +45,15 @@ class EdgeSave(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None - animated: bool = False + animated: str = 'none' source_handle: str | None = None target_handle: str | None = None + @field_validator('animated', mode='before') + @classmethod + def validate_animated(cls, v: object) -> str: + return normalize_animated(v) + class CanvasSaveRequest(BaseModel): nodes: list[NodeSave] = [] diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 0e77c9a..a663806 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -1,6 +1,8 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + +from app.schemas.utils import normalize_animated class EdgeBase(BaseModel): @@ -12,10 +14,15 @@ class EdgeBase(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None - animated: bool = False + animated: str = 'none' source_handle: str | None = None target_handle: str | None = None + @field_validator('animated', mode='before') + @classmethod + def validate_animated(cls, v: object) -> str: + return normalize_animated(v) + class EdgeCreate(EdgeBase): pass @@ -28,10 +35,17 @@ class EdgeUpdate(BaseModel): speed: str | None = None custom_color: str | None = None path_style: str | None = None - animated: bool | None = None + animated: str | None = None source_handle: str | None = None target_handle: str | None = None + @field_validator('animated', mode='before') + @classmethod + def validate_animated(cls, v: object) -> str | None: + if v is None: + return None + return normalize_animated(v) + class EdgeResponse(EdgeBase): id: str diff --git a/backend/app/schemas/utils.py b/backend/app/schemas/utils.py new file mode 100644 index 0000000..fab5721 --- /dev/null +++ b/backend/app/schemas/utils.py @@ -0,0 +1,9 @@ +def normalize_animated(v: object) -> str: + """Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow').""" + if v is True or v == 1 or v == '1': + return 'snake' + if v is False or v == 0 or v == '0' or v is None or v == 'none': + return 'none' + if v in ('snake', 'flow'): + return str(v) + return 'none' diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index f824ed7..7541398 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -98,18 +98,32 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) - it('onNodesChange marks unsaved', () => { + it('onNodesChange marks unsaved for position changes', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().markSaved() - useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }]) + useCanvasStore.getState().onNodesChange([{ type: 'position', id: 'n1', dragging: false }]) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) - it('onEdgesChange marks unsaved', () => { + it('onNodesChange does not mark unsaved for select-only changes', () => { + useCanvasStore.getState().addNode(makeNode('n1')) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }]) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('onEdgesChange marks unsaved for remove changes', () => { + useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().onEdgesChange([{ type: 'remove', id: 'e1' }]) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('onEdgesChange does not mark unsaved for select-only changes', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().markSaved() useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }]) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) }) it('onConnect adds an edge between two nodes', () => { @@ -130,6 +144,13 @@ describe('canvasStore', () => { expect(edges[0].data?.label).toBe('uplink') }) + it('onConnect preserves animated from edge data', () => { + const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', animated: 'snake' }) + useCanvasStore.getState().onConnect(conn) + const { edges } = useCanvasStore.getState() + expect(edges[0].data?.animated).toBe('snake') + }) + it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => { const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' }) useCanvasStore.getState().onConnect(conn) @@ -140,6 +161,15 @@ describe('canvasStore', () => { expect(edges[0].type).toBe('cluster') }) + it('deleteNode also removes children with matching parentId', () => { + useCanvasStore.getState().addNode(makeNode('parent')) + useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) + useCanvasStore.getState().deleteNode('parent') + const { nodes } = useCanvasStore.getState() + expect(nodes.find((n) => n.id === 'parent')).toBeUndefined() + expect(nodes.find((n) => n.id === 'child')).toBeUndefined() + }) + it('addNode with parent_id sets parentId and extent', () => { useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 32064be..fec044b 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -127,13 +127,13 @@ export const useCanvasStore = create((set) => ({ onNodesChange: (changes) => set((state) => ({ nodes: applyNodeChanges(changes, state.nodes), - hasUnsavedChanges: true, + hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), })), onEdgesChange: (changes) => set((state) => ({ edges: applyEdgeChanges(changes, state.edges), - hasUnsavedChanges: true, + hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), })), onConnect: (connection) => @@ -150,7 +150,7 @@ export const useCanvasStore = create((set) => ({ sourceHandle: normalizeHandle(extra.sourceHandle), targetHandle: normalizeHandle(extra.targetHandle), type: edgeType, - data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style }, + data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated }, }, state.edges), hasUnsavedChanges: true, } @@ -163,10 +163,13 @@ export const useCanvasStore = create((set) => ({ const enriched = node.data.parent_id ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } : node - // Parents must come before children in the array + // Parents must come before children in the array (React Flow requirement) const withoutNew = state.nodes.filter((n) => n.id !== node.id) if (enriched.parentId) { - return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } + const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId) + const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length + const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)] + return { nodes, hasUnsavedChanges: true } } return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } }), @@ -180,12 +183,20 @@ export const useCanvasStore = create((set) => ({ })), deleteNode: (id) => - set((state) => ({ - nodes: state.nodes.filter((n) => n.id !== id), - edges: state.edges.filter((e) => e.source !== id && e.target !== id), - selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId, - hasUnsavedChanges: true, - })), + set((state) => { + const idsToRemove = new Set() + const collect = (nodeId: string) => { + idsToRemove.add(nodeId) + state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id)) + } + collect(id) + return { + nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)), + edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)), + selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId, + hasUnsavedChanges: true, + } + }), updateEdge: (id, data) => set((state) => ({ @@ -245,6 +256,6 @@ export const useCanvasStore = create((set) => ({ // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) - set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null }) + set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] }) }, })) From 37cb97dca1c473ecd8b26db61399574acf186b87 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 14:46:23 +0200 Subject: [PATCH 4/6] fix: add border_width, text_size, label_position to custom_colors type --- frontend/src/types/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1bd9915..59379a8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -72,6 +72,9 @@ export interface NodeData extends Record { text_position?: TextPosition font?: string border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none' + border_width?: number + label_position?: 'inside' | 'outside' + text_size?: number z_order?: number width?: number height?: number @@ -88,7 +91,7 @@ export interface EdgeData extends Record { speed?: string custom_color?: string path_style?: EdgePathStyle - animated?: boolean + animated?: boolean | 'snake' | 'flow' | 'none' } export const NODE_TYPE_LABELS: Record = { From 95a3db34f148f9b04852d6956f0555494993afae Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 14:57:25 +0200 Subject: [PATCH 5/6] fix: move AnimMode type to module scope, fix tsc -b build error --- frontend/src/components/modals/EdgeModal.tsx | 45 +++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index ff228ab..95d07b7 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -10,6 +10,14 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors' const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][] +type AnimMode = 'none' | 'snake' | 'flow' + +function toAnimMode(v: EdgeData['animated']): AnimMode { + if (v === true || v === 'snake') return 'snake' + if (v === 'flow') return 'flow' + return 'none' +} + interface EdgeModalProps { open: boolean onClose: () => void @@ -25,7 +33,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '') const [customColor, setCustomColor] = useState(initial?.custom_color) const [pathStyle, setPathStyle] = useState(initial?.path_style ?? 'bezier') - const [animated, setAnimated] = useState(initial?.animated ?? false) + const [animation, setAnimation] = useState(() => toAnimMode(initial?.animated)) const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type] @@ -37,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, custom_color: customColor, path_style: pathStyle, - animated: animated || undefined, + animated: animation !== 'none' ? animation : undefined, }) onClose() } @@ -115,20 +123,25 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
-
- - +
+ +
+ {(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( + + ))} +
From 7ed6b77165bafb704a1bf9429d503f73a4463933 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 29 Mar 2026 15:01:20 +0200 Subject: [PATCH 6/6] fix: update EdgeModal tests for None/Snake/Flow animation selector --- .../modals/__tests__/EdgeModal.test.tsx | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index 8918dd8..9d9cd51 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -97,26 +97,52 @@ describe('EdgeModal', () => { expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth') }) - // ── Animated toggle ─────────────────────────────────────────────────────── + // ── Animation select ────────────────────────────────────────────────────── - it('flow animation defaults to off', () => { + it('animation defaults to None — animated omitted from payload', () => { const onSubmit = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: 'Connect' })) - // animated: false → omitted (falsy || undefined) - expect(onSubmit.mock.calls[0][0].animated).toBeFalsy() + expect(onSubmit.mock.calls[0][0].animated).toBeUndefined() }) - it('toggling animation sends animated: true', () => { + it('selecting Snake sends animated: "snake"', () => { const onSubmit = vi.fn() render() - // Find the toggle: it's the only button with aria-pressed attribute - const allButtons = screen.getAllByRole('button') - const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))! - expect(toggle).toBeDefined() - fireEvent.click(toggle) + fireEvent.click(screen.getByText('Snake')) fireEvent.click(screen.getByRole('button', { name: 'Connect' })) - expect(onSubmit.mock.calls[0][0].animated).toBe(true) + expect(onSubmit.mock.calls[0][0].animated).toBe('snake') + }) + + it('selecting Flow sends animated: "flow"', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Flow')) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('flow') + }) + + it('selecting None after Snake omits animated from payload', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Snake')) + fireEvent.click(screen.getByText('None')) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBeUndefined() + }) + + it('pre-fills animation from initial "snake" string', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('snake') + }) + + it('pre-fills animation from legacy initial true (backward compat)', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('snake') }) // ── Pre-fill ──────────────────────────────────────────────────────────────