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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user