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
This commit is contained in:
+22
-15
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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' }))
|
||||
|
||||
@@ -127,13 +127,13 @@ export const useCanvasStore = create<CanvasState>((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<CanvasState>((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<CanvasState>((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<CanvasState>((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<string>()
|
||||
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<CanvasState>((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: [] })
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user