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'
|
return 'snake'
|
||||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
||||||
return 'none'
|
return 'none'
|
||||||
if v in ('snake', 'flow'):
|
if v in ('snake', 'flow', 'basic'):
|
||||||
return str(v)
|
return str(v)
|
||||||
return 'none'
|
return 'none'
|
||||||
|
|||||||
@@ -379,6 +379,16 @@ async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers:
|
|||||||
assert canvas["edges"][0]["animated"] == "snake"
|
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 ───────────────────────────────────────────────────────────────
|
# ── node fields ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
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)` } : {}),
|
...(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 === 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)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
|
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} animated={animMode === 'basic'} />
|
||||||
|
|
||||||
{animMode === 'snake' && (
|
{animMode === 'snake' && (
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
|||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
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 {
|
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
||||||
if (v === true || v === 'snake') return 'snake'
|
if (v === true || v === 'snake') return 'snake'
|
||||||
if (v === 'flow') return 'flow'
|
if (v === 'flow') return 'flow'
|
||||||
|
if (v === 'basic') return 'basic'
|
||||||
return 'none'
|
return 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
<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
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
style={{
|
style={{
|
||||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
|
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', () => {
|
it('selecting None after Snake omits animated from payload', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
speed?: string
|
speed?: string
|
||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
waypoints?: Waypoint[]
|
waypoints?: Waypoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user