feat: add Basic animation type for edges

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
This commit is contained in:
Pouzor
2026-04-09 15:46:52 +02:00
parent 4976f2e694
commit 4844576c3b
6 changed files with 36 additions and 9 deletions
+1 -1
View File
@@ -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'
+10
View File
@@ -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):
@@ -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 (
<>
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} animated={animMode === 'basic'} />
{animMode === 'snake' && (
<path
+5 -4
View File
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow'
type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
if (v === 'basic') return 'basic'
return 'none'
}
@@ -127,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button
key={mode}
type="button"
@@ -136,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
style={{
background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e',
borderRight: i < 2 ? '1px solid #30363d' : undefined,
borderRight: i < 3 ? '1px solid #30363d' : undefined,
}}
>
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
@@ -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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
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(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
+1 -1
View File
@@ -107,7 +107,7 @@ export interface EdgeData extends Record<string, unknown> {
speed?: string
custom_color?: string
path_style?: EdgePathStyle
animated?: boolean | 'snake' | 'flow' | 'none'
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
waypoints?: Waypoint[]
}