From 4844576c3b077a823a038149194dd875f60275f4 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 9 Apr 2026 15:46:52 +0200 Subject: [PATCH] feat: add Basic animation type for edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "basic" animation mode that uses React Flow's native animated dashes — the entire stroke moves as dashes, with no solid line underneath. Distinct from "snake" (moving dot) and "flow" (overlay on solid line). - Frontend: new Basic button in EdgeModal, animMode mapping, passes animated={true} to BaseEdge when mode is "basic" - Backend: normalize_animated accepts "basic" as a valid value - Tests: EdgeModal + canvas round-trip tests for basic mode --- backend/app/schemas/utils.py | 2 +- backend/tests/test_canvas.py | 10 ++++++++++ frontend/src/components/canvas/edges/index.tsx | 7 ++++--- frontend/src/components/modals/EdgeModal.tsx | 9 +++++---- .../modals/__tests__/EdgeModal.test.tsx | 15 +++++++++++++++ frontend/src/types/index.ts | 2 +- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/backend/app/schemas/utils.py b/backend/app/schemas/utils.py index fab5721..21e8770 100644 --- a/backend/app/schemas/utils.py +++ b/backend/app/schemas/utils.py @@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str: 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'): + if v in ('snake', 'flow', 'basic'): return str(v) return 'none' diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 6eb267f..7b25f25 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -379,6 +379,16 @@ async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: assert canvas["edges"][0]["animated"] == "snake" +async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict): + n1 = node_payload() + n2 = node_payload() + e1 = edge_payload(n1["id"], n2["id"], animated="basic") + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["edges"][0]["animated"] == "basic" + + # ── node fields ─────────────────────────────────────────────────────────────── async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict): diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 23ac77c..630db31 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -233,9 +233,10 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } - const animMode: 'none' | 'snake' | 'flow' = + const animMode: 'none' | 'snake' | 'flow' | 'basic' = data?.animated === true || data?.animated === 'snake' ? 'snake' : - data?.animated === 'flow' ? 'flow' : 'none' + data?.animated === 'flow' ? 'flow' : + data?.animated === 'basic' ? 'basic' : 'none' const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) @@ -245,7 +246,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t return ( <> - + {animMode === 'snake' && (
- {(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( + {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( ))}
diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index 83c738d..7b97730 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -122,6 +122,21 @@ describe('EdgeModal', () => { expect(onSubmit.mock.calls[0][0].animated).toBe('flow') }) + it('selecting Basic sends animated: "basic"', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Basic')) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('basic') + }) + + it('pre-fills animation from initial "basic" string', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].animated).toBe('basic') + }) + it('selecting None after Snake omits animated from payload', () => { const onSubmit = vi.fn() render() diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3437c01..2481349 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -107,7 +107,7 @@ export interface EdgeData extends Record { speed?: string custom_color?: string path_style?: EdgePathStyle - animated?: boolean | 'snake' | 'flow' | 'none' + animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' waypoints?: Waypoint[] }