Merge pull request #249 from Pouzor/feat/customizable-connection-points-per-side
feat: customizable connection points per side (#243)
This commit is contained in:
@@ -99,6 +99,12 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN top_handles INTEGER NOT NULL DEFAULT 1")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN left_handles INTEGER NOT NULL DEFAULT 0")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ class Node(Base):
|
|||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
top_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
left_handles: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
right_handles: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ class NodeSave(BaseModel):
|
|||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
bottom_handles: int = 1
|
||||||
|
top_handles: int = 1
|
||||||
|
left_handles: int = 0
|
||||||
|
right_handles: int = 0
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class NodeBase(BaseModel):
|
|||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
bottom_handles: int = 1
|
||||||
|
top_handles: int = 1
|
||||||
|
left_handles: int = 0
|
||||||
|
right_handles: int = 0
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -66,6 +69,9 @@ class NodeUpdate(BaseModel):
|
|||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int | None = None
|
bottom_handles: int | None = None
|
||||||
|
top_handles: int | None = None
|
||||||
|
left_handles: int | None = None
|
||||||
|
right_handles: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -51,6 +51,32 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
|
|||||||
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
||||||
|
# Regression (#243): top/left/right_handles must persist across save+reload,
|
||||||
|
# not just bottom_handles.
|
||||||
|
n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4)
|
||||||
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||||
|
assert node["top_handles"] == 2
|
||||||
|
assert node["bottom_handles"] == 3
|
||||||
|
assert node["left_handles"] == 1
|
||||||
|
assert node["right_handles"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
|
||||||
|
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
|
||||||
|
n1 = node_payload(label="Srv", type="server")
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
||||||
|
|
||||||
|
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||||
|
assert node["top_handles"] == 1
|
||||||
|
assert node["bottom_handles"] == 1
|
||||||
|
assert node["left_handles"] == 0
|
||||||
|
assert node["right_handles"] == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(label="Router", type="router")
|
n1 = node_payload(label="Router", type="router")
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|||||||
+21
-10
@@ -2,7 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
@@ -28,6 +28,7 @@ import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
|||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
|
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||||
@@ -41,7 +42,7 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
|||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
||||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export default function App() {
|
|||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
|
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
@@ -125,8 +127,10 @@ export default function App() {
|
|||||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n) => [n.id, true])
|
.map((n) => [n.id, true])
|
||||||
)
|
)
|
||||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||||
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
|
)
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
@@ -154,7 +158,8 @@ export default function App() {
|
|||||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
// Floor plans are backend-only; keep the store clear in standalone mode.
|
// Floor plans are backend-only; keep the store clear in standalone mode.
|
||||||
setFloorMap(null)
|
setFloorMap(null)
|
||||||
loadCanvas(saved.nodes, saved.edges)
|
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
|
||||||
|
loadCanvas(migrated.nodes, migrated.edges)
|
||||||
} else {
|
} else {
|
||||||
setFloorMap(null)
|
setFloorMap(null)
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
@@ -752,6 +757,7 @@ export default function App() {
|
|||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||||
|
onEditTypeStyle={setStyleEditorType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -781,6 +787,7 @@ export default function App() {
|
|||||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||||
})()}
|
})()}
|
||||||
currentNodeId={editNodeId ?? undefined}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
|
onEditTypeStyle={setStyleEditorType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -788,11 +795,6 @@ export default function App() {
|
|||||||
open={!!pendingConnection}
|
open={!!pendingConnection}
|
||||||
onClose={() => setPendingConnection(null)}
|
onClose={() => setPendingConnection(null)}
|
||||||
onSubmit={handleEdgeConfirm}
|
onSubmit={handleEdgeConfirm}
|
||||||
initial={
|
|
||||||
pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster')
|
|
||||||
? { type: 'cluster' }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -918,6 +920,15 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Standalone Custom Style editor, opened from a node's Appearance
|
||||||
|
shortcut with that node's type preselected. */}
|
||||||
|
<CustomStyleModal
|
||||||
|
key={styleEditorType ? `style-${styleEditorType}` : 'style-closed'}
|
||||||
|
open={styleEditorType !== null}
|
||||||
|
initialNodeType={styleEditorType ?? undefined}
|
||||||
|
onClose={() => setStyleEditorType(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
@@ -88,10 +88,11 @@ function LiveViewCanvas() {
|
|||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(
|
const migrated = migrateClusterHandles(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
)
|
)
|
||||||
|
loadCanvas(migrated.nodes, migrated.edges)
|
||||||
setViewState('ready')
|
setViewState('ready')
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
connectionRadius={30}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ let mockZoom = 1
|
|||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Top: 'top', Bottom: 'bottom' },
|
Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
|
||||||
NodeResizer: () => null,
|
NodeResizer: () => null,
|
||||||
useUpdateNodeInternals: () => vi.fn(),
|
useUpdateNodeInternals: () => vi.fn(),
|
||||||
useViewport: () => ({ zoom: mockZoom }),
|
useViewport: () => ({ zoom: mockZoom }),
|
||||||
@@ -59,14 +59,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
|||||||
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
// handleUtils is pure — use the real implementation so the side-generic API
|
||||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
// (SIDES, handleId, handlePositions, sideHandleCount, …) stays in sync.
|
||||||
bottomHandlePositions: (count: number) => {
|
vi.mock('@/utils/handleUtils', async (importOriginal) => await importOriginal())
|
||||||
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
|
|
||||||
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
|
||||||
},
|
|
||||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
beforeEach(() => { mockZoom = 1 })
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
@@ -124,6 +119,14 @@ describe('BaseNode — borderWidth zoom scaling', () => {
|
|||||||
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: the node root must NOT clip its own bounds, otherwise the outer
|
||||||
|
// half of each connection handle (which sits centred on the edge) becomes
|
||||||
|
// non-interactive and the "magnet" snap area is halved.
|
||||||
|
it('root does not clip overflow (keeps handles grabbable)', () => {
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).className).not.toContain('overflow-hidden')
|
||||||
|
})
|
||||||
|
|
||||||
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
||||||
mockZoom = 0.5
|
mockZoom = 0.5
|
||||||
const node = makeNode({ status: 'online' })
|
const node = makeNode({ status: 'online' })
|
||||||
@@ -176,15 +179,23 @@ describe('BaseNode — properties rendering', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('BaseNode — port numbers (issue #20)', () => {
|
describe('BaseNode — port numbers (issue #20 / #243)', () => {
|
||||||
it('renders a number above each bottom handle when show_port_numbers is on', () => {
|
it('renders a number on each connection point when show_port_numbers is on', () => {
|
||||||
|
// bottom=4 → labels 1..4; top defaults to 1 → an extra "1" on top.
|
||||||
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
|
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
|
||||||
expect(screen.getByText('1')).toBeDefined()
|
expect(screen.getAllByText('1')).toHaveLength(2) // top slot 0 + bottom slot 0
|
||||||
expect(screen.getByText('2')).toBeDefined()
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
expect(screen.getByText('3')).toBeDefined()
|
expect(screen.getByText('3')).toBeDefined()
|
||||||
expect(screen.getByText('4')).toBeDefined()
|
expect(screen.getByText('4')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('labels left/right connection points too when enabled', () => {
|
||||||
|
// top=1, bottom=1, left=2, right=0 → labels: two "1" (top+bottom) + "2" (left).
|
||||||
|
renderBaseNode({ bottom_handles: 1, left_handles: 2, show_port_numbers: true })
|
||||||
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
|
expect(screen.getAllByText('1')).toHaveLength(3) // top + bottom + left slot 0
|
||||||
|
})
|
||||||
|
|
||||||
it('does not render port numbers when show_port_numbers is off', () => {
|
it('does not render port numbers when show_port_numbers is off', () => {
|
||||||
renderBaseNode({ bottom_handles: 4 })
|
renderBaseNode({ bottom_handles: 4 })
|
||||||
expect(screen.queryByText('1')).toBeNull()
|
expect(screen.queryByText('1')).toBeNull()
|
||||||
@@ -192,8 +203,9 @@ describe('BaseNode — port numbers (issue #20)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('numbers match the handle count', () => {
|
it('numbers match the handle count', () => {
|
||||||
|
// bottom=2 → 1,2; top default 1 adds one more "1"; no "3".
|
||||||
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
|
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
|
||||||
expect(screen.getByText('1')).toBeDefined()
|
expect(screen.getAllByText('1')).toHaveLength(2)
|
||||||
expect(screen.getByText('2')).toBeDefined()
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
expect(screen.queryByText('3')).toBeNull()
|
expect(screen.queryByText('3')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useEffect, useMemo } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
import { NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -10,7 +10,8 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||||
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
import { sideHandleCount } from '@/utils/handleUtils'
|
||||||
|
import { SideHandles } from './SideHandles'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
@@ -24,7 +25,7 @@ function formatStorage(gb: number): string {
|
|||||||
|
|
||||||
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const { zoom } = useViewport()
|
const { zoom } = useViewport()
|
||||||
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
||||||
@@ -47,9 +48,15 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const showLegacyHardware = !data.properties && data.show_hardware &&
|
const showLegacyHardware = !data.properties && data.show_hardware &&
|
||||||
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||||
|
|
||||||
|
// Resolved per-side connection-point counts (missing field → side default).
|
||||||
|
const topCount = sideHandleCount(data, 'top')
|
||||||
|
const bottomCount = sideHandleCount(data, 'bottom')
|
||||||
|
const leftCount = sideHandleCount(data, 'left')
|
||||||
|
const rightCount = sideHandleCount(data, 'right')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
@@ -62,8 +69,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
// Grow node so each handle stays clickable (~14px slot on each axis).
|
||||||
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
minWidth: Math.max(140, Math.max(topCount, bottomCount) * 14),
|
||||||
|
minHeight: Math.max(50, Math.max(leftCount, rightCount) * 14),
|
||||||
width: width ? '100%' : undefined,
|
width: width ? '100%' : undefined,
|
||||||
height: height ? '100%' : undefined,
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -73,15 +81,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
minWidth={140}
|
minWidth={140}
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8, borderRadius: 2 }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<SideHandles
|
||||||
type="source"
|
data={data}
|
||||||
position={Position.Top}
|
sides={['top', 'left', 'right']}
|
||||||
id="top"
|
handleBackground={theme.colors.handleBackground}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
handleBorder={theme.colors.handleBorder}
|
||||||
|
labelColor={theme.colors.nodeSubtextColor}
|
||||||
|
showLabels
|
||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
|
||||||
|
|
||||||
{/* Status dot — absolute to avoid affecting node auto-width */}
|
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||||
<div
|
<div
|
||||||
@@ -251,40 +260,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
<SideHandles
|
||||||
const sourceId = bottomHandleId(idx)
|
data={data}
|
||||||
const targetId = `${sourceId}-t`
|
sides={['bottom']}
|
||||||
return (
|
handleBackground={theme.colors.handleBackground}
|
||||||
<span key={sourceId}>
|
handleBorder={theme.colors.handleBorder}
|
||||||
{data.show_port_numbers && (
|
labelColor={theme.colors.nodeSubtextColor}
|
||||||
<span
|
showLabels
|
||||||
className="absolute font-mono leading-none pointer-events-none select-none"
|
/>
|
||||||
style={{
|
|
||||||
left: `${leftPct}%`,
|
|
||||||
bottom: 3,
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
fontSize: 7,
|
|
||||||
color: theme.colors.nodeSubtextColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={sourceId}
|
|
||||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={targetId}
|
|
||||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useEffect } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
import { NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -10,47 +10,28 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
import { SideHandles } from './SideHandles'
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||||
const { id, data, selected } = props
|
const { id, data, selected } = props
|
||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
|
|
||||||
// Render as a regular node when container mode is disabled
|
// Render as a regular node when container mode is disabled. Cluster links now
|
||||||
|
// use the configurable per-side connection points (see BaseNode / SideHandles).
|
||||||
if (data.container_mode === false) {
|
if (data.container_mode === false) {
|
||||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
return <BaseNode {...props} icon={Layers} />
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BaseNode {...props} icon={Layers} />
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="cluster-left"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="cluster-right"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
const glow = colors.border
|
const glow = colors.border
|
||||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
|
||||||
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,48 +126,11 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
<div className="flex-1 relative" />
|
<div className="flex-1 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<SideHandles
|
||||||
type="source"
|
data={data}
|
||||||
position={Position.Top}
|
handleBackground={theme.colors.handleBackground}
|
||||||
id="top"
|
handleBorder={theme.colors.handleBorder}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
labelColor={theme.colors.nodeSubtextColor}
|
||||||
/>
|
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
|
||||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
|
||||||
const sourceId = bottomHandleId(idx)
|
|
||||||
const targetId = `${sourceId}-t`
|
|
||||||
return (
|
|
||||||
<span key={sourceId}>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={sourceId}
|
|
||||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={targetId}
|
|
||||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Cluster handles */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="cluster-left"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="cluster-right"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { Handle, Position } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import {
|
||||||
|
SIDES,
|
||||||
|
handleId,
|
||||||
|
handlePositions,
|
||||||
|
isVerticalSide,
|
||||||
|
sideHandleCount,
|
||||||
|
type Side,
|
||||||
|
} from '@/utils/handleUtils'
|
||||||
|
|
||||||
|
const POSITION: Record<Side, Position> = {
|
||||||
|
top: Position.Top,
|
||||||
|
bottom: Position.Bottom,
|
||||||
|
left: Position.Left,
|
||||||
|
right: Position.Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideHandlesProps {
|
||||||
|
data: NodeData
|
||||||
|
handleBackground: string
|
||||||
|
handleBorder: string
|
||||||
|
/** Colour for the optional port-number labels. */
|
||||||
|
labelColor: string
|
||||||
|
/** Which sides to render. Defaults to all four. */
|
||||||
|
sides?: readonly Side[]
|
||||||
|
/** When true, render port-number labels if data.show_port_numbers is set. */
|
||||||
|
showLabels?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the per-side React Flow handles (visible source + invisible target)
|
||||||
|
* for a node, spaced along each side's axis. Shared by BaseNode and the
|
||||||
|
* container-mode ProxmoxGroupNode so handle IDs stay identical across both.
|
||||||
|
*/
|
||||||
|
export function SideHandles({
|
||||||
|
data,
|
||||||
|
handleBackground,
|
||||||
|
handleBorder,
|
||||||
|
labelColor,
|
||||||
|
sides = SIDES,
|
||||||
|
showLabels = false,
|
||||||
|
}: SideHandlesProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sides.map((side) => {
|
||||||
|
const vertical = isVerticalSide(side)
|
||||||
|
return handlePositions(side, sideHandleCount(data, side)).map((pct, idx) => {
|
||||||
|
const sourceId = handleId(side, idx)
|
||||||
|
const targetId = `${sourceId}-t`
|
||||||
|
const offset: CSSProperties = vertical ? { top: `${pct}%` } : { left: `${pct}%` }
|
||||||
|
const labelStyle: CSSProperties = vertical
|
||||||
|
? { top: `${pct}%`, [side]: 3, transform: 'translateY(-50%)' }
|
||||||
|
: { left: `${pct}%`, [side]: 3, transform: 'translateX(-50%)' }
|
||||||
|
return (
|
||||||
|
<span key={sourceId}>
|
||||||
|
{showLabels && data.show_port_numbers && (
|
||||||
|
<span
|
||||||
|
className="absolute font-mono leading-none pointer-events-none select-none"
|
||||||
|
style={{ ...labelStyle, fontSize: 7, color: labelColor }}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={POSITION[side]}
|
||||||
|
id={sourceId}
|
||||||
|
style={{ ...offset, background: handleBackground, borderColor: handleBorder }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={POSITION[side]}
|
||||||
|
id={targetId}
|
||||||
|
style={{ ...offset, opacity: 0, width: 20, height: 20 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -114,10 +114,20 @@ describe('ProxmoxGroupNode', () => {
|
|||||||
expect(sourceHandles.length).toBe(1)
|
expect(sourceHandles.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders cluster handles in both modes', () => {
|
it('no longer renders the always-on cluster handles (#243)', () => {
|
||||||
const { container: groupC } = renderNode({})
|
const { container: groupC } = renderNode({})
|
||||||
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBe(0)
|
||||||
const { container: nodeC } = renderNode({ container_mode: false })
|
const { container: nodeC } = renderNode({ container_mode: false })
|
||||||
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders configurable left/right handles only when counts > 0', () => {
|
||||||
|
const { container: none } = renderNode({ container_mode: false })
|
||||||
|
expect(none.querySelectorAll('.react-flow__handle-left.source').length).toBe(0)
|
||||||
|
expect(none.querySelectorAll('.react-flow__handle-right.source').length).toBe(0)
|
||||||
|
|
||||||
|
const { container: set } = renderNode({ container_mode: false, left_handles: 1, right_handles: 2 })
|
||||||
|
expect(set.querySelectorAll('.react-flow__handle-left.source').length).toBe(1)
|
||||||
|
expect(set.querySelectorAll('.react-flow__handle-right.source').length).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { SideHandles } from '../SideHandles'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
function renderHandles(data: Partial<NodeData> = {}) {
|
||||||
|
const full: NodeData = { label: 'n', type: 'server', status: 'online', services: [], ...data }
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<SideHandles
|
||||||
|
data={full}
|
||||||
|
handleBackground="#30363d"
|
||||||
|
handleBorder="#30363d"
|
||||||
|
labelColor="#8b949e"
|
||||||
|
/>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SideHandles', () => {
|
||||||
|
it('renders a source + invisible target handle per slot', () => {
|
||||||
|
// default node: top=1, bottom=1, left=0, right=0
|
||||||
|
const { container } = renderHandles({})
|
||||||
|
expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(2)
|
||||||
|
expect(container.querySelectorAll('.react-flow__handle.target').length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('target (magnet) handle hit area is large enough to snap onto (20px)', () => {
|
||||||
|
const { container } = renderHandles({})
|
||||||
|
const target = container.querySelector('.react-flow__handle.target') as HTMLElement
|
||||||
|
expect(target.style.width).toBe('20px')
|
||||||
|
expect(target.style.height).toBe('20px')
|
||||||
|
expect(target.style.opacity).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders configured per-side counts', () => {
|
||||||
|
const { container } = renderHandles({ top_handles: 2, left_handles: 3, right_handles: 1, bottom_handles: 1 })
|
||||||
|
expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { clampHandles, sideDefault } from '@/utils/handleUtils'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
import type {
|
import type {
|
||||||
@@ -178,6 +179,33 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#30363d] pt-3">
|
||||||
|
<div className="text-xs text-[#8b949e] mb-1">Default connection points</div>
|
||||||
|
<div className="text-xs text-[#8b949e]/60 mb-2">New {NODE_TYPE_LABELS[nodeType]} nodes start with these (0–64 per side)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{([
|
||||||
|
['Top', 'top', 'topHandles'],
|
||||||
|
['Right', 'right', 'rightHandles'],
|
||||||
|
['Bottom', 'bottom', 'bottomHandles'],
|
||||||
|
['Left', 'left', 'leftHandles'],
|
||||||
|
] as const).map(([label, side, key]) => (
|
||||||
|
<div key={side} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e] w-12">{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={sideDefault(side)}
|
||||||
|
max={64}
|
||||||
|
step={1}
|
||||||
|
value={style[key] ?? sideDefault(side)}
|
||||||
|
onChange={(e) => set(key, clampHandles(side, parseInt(e.target.value, 10)))}
|
||||||
|
aria-label={`${label} default connection points`}
|
||||||
|
className="w-16 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
@@ -272,9 +300,11 @@ type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeTy
|
|||||||
interface CustomStyleModalProps {
|
interface CustomStyleModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
/** When opening, preselect this node type's editor (shortcut from NodeModal). */
|
||||||
|
initialNodeType?: NodeType
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
export function CustomStyleModal({ open, onClose, initialNodeType }: CustomStyleModalProps) {
|
||||||
const { customStyle, setCustomStyle } = useThemeStore()
|
const { customStyle, setCustomStyle } = useThemeStore()
|
||||||
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
||||||
|
|
||||||
@@ -292,7 +322,12 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
setSelection(null)
|
if (initialNodeType) {
|
||||||
|
setTab('nodes')
|
||||||
|
setSelection({ kind: 'node', type: initialNodeType })
|
||||||
|
} else {
|
||||||
|
setSelection(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
||||||
// clobber an in-progress edit, only a fresh open should reset.
|
// clobber an in-progress edit, only a fresh open should reset.
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Fragment, createElement, useState } from 'react'
|
import { Fragment, createElement, useState } from 'react'
|
||||||
import modalStyles from './modal-interactive.module.css'
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
import { RotateCcw, ChevronDown, Palette } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||||
import { BrandIconPicker } from './BrandIconPicker'
|
import { BrandIconPicker } from './BrandIconPicker'
|
||||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
@@ -24,6 +26,65 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
|||||||
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Maps a side to its per-type default field on NodeTypeStyle.
|
||||||
|
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
|
||||||
|
top: 'topHandles',
|
||||||
|
bottom: 'bottomHandles',
|
||||||
|
left: 'leftHandles',
|
||||||
|
right: 'rightHandles',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact per-side connection-point control: [− N +] with a typable value.
|
||||||
|
* Placed spatially around a node preview (see the Connection Points section).
|
||||||
|
*/
|
||||||
|
function CPStepper({ label, side, value, onChange }: {
|
||||||
|
label: string
|
||||||
|
side: Side
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
const min = sideDefault(side)
|
||||||
|
const labelEl = <span className="text-[10px] text-muted-foreground/80 leading-none">{label}</span>
|
||||||
|
const belowLabel = side === 'bottom'
|
||||||
|
const btn = 'w-6 h-full flex items-center justify-center text-sm text-muted-foreground hover:text-foreground hover:bg-[#21262d] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-default'
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
{!belowLabel && labelEl}
|
||||||
|
<div className="flex items-center h-7 rounded-md border border-[#30363d] bg-[#0d1117] overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Decrease ${label} connection points`}
|
||||||
|
onClick={() => onChange(clampHandles(side, value - 1))}
|
||||||
|
disabled={value <= min}
|
||||||
|
className={btn}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={MAX_HANDLES}
|
||||||
|
value={value}
|
||||||
|
aria-label={`${label} connection points`}
|
||||||
|
onChange={(e) => onChange(clampHandles(side, Number(e.target.value)))}
|
||||||
|
className="w-9 h-full bg-transparent text-center text-xs font-mono text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Increase ${label} connection points`}
|
||||||
|
onClick={() => onChange(clampHandles(side, value + 1))}
|
||||||
|
disabled={value >= MAX_HANDLES}
|
||||||
|
className={btn}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{belowLabel && labelEl}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
||||||
@@ -71,11 +132,13 @@ interface NodeModalProps {
|
|||||||
title?: string
|
title?: string
|
||||||
parentCandidates?: ParentCandidate[]
|
parentCandidates?: ParentCandidate[]
|
||||||
currentNodeId?: string
|
currentNodeId?: string
|
||||||
|
/** Shortcut: open the Custom Style editor for this node's type (canvas-wide). */
|
||||||
|
onEditTypeStyle?: (type: NodeType) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId, onEditTypeStyle }: NodeModalProps) {
|
||||||
const merged = { ...DEFAULT_DATA, ...initial }
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
@@ -94,6 +157,16 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const customStyle = useThemeStore((s) => s.customStyle)
|
||||||
|
// Effective default count for a side: the per-type style default if set,
|
||||||
|
// otherwise the intrinsic side default (top/bottom → 1, left/right → 0).
|
||||||
|
const effectiveSideDefault = (side: Side): number => {
|
||||||
|
const styleVal = customStyle.nodes[(form.type ?? 'generic') as NodeType]?.[SIDE_STYLE_KEY[side]]
|
||||||
|
return clampHandles(side, typeof styleVal === 'number' ? styleVal : sideDefault(side))
|
||||||
|
}
|
||||||
|
const sideValue = (side: Side): number =>
|
||||||
|
clampHandles(side, form[handleCountField(side)] ?? effectiveSideDefault(side))
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.label?.trim()) {
|
if (!form.label?.trim()) {
|
||||||
@@ -113,8 +186,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||||
}
|
}
|
||||||
|
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
|
// Persist the resolved per-side counts so type-style defaults (and
|
||||||
|
// untouched sliders) are baked into the node. Skipped for group types.
|
||||||
|
...(isGroupType ? {} : {
|
||||||
|
top_handles: sideValue('top'),
|
||||||
|
bottom_handles: sideValue('bottom'),
|
||||||
|
left_handles: sideValue('left'),
|
||||||
|
right_handles: sideValue('right'),
|
||||||
|
}),
|
||||||
parent_id: safeParentId,
|
parent_id: safeParentId,
|
||||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
})
|
})
|
||||||
@@ -123,13 +205,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
|
{/* ── LEFT column: identity & network ── */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Information</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Type + Icon on the same row */}
|
{/* Type + Icon on the same row */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
@@ -208,6 +294,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* end Type/Icon subgrid */}
|
||||||
|
|
||||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
@@ -305,6 +392,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Hostname */}
|
{/* Hostname */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Hostname</Label>
|
<Label className="text-xs text-muted-foreground">Hostname</Label>
|
||||||
@@ -327,7 +415,9 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* end Hostname/IP subgrid */}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
||||||
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -357,6 +447,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>{/* end Check method/target subgrid */}
|
||||||
|
|
||||||
{/* Parent Container */}
|
{/* Parent Container */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -423,7 +514,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.notes ?? ''}
|
||||||
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
|
placeholder="Optional notes"
|
||||||
|
rows={3}
|
||||||
|
className={`bg-[#21262d] border-[#30363d] text-sm resize-y min-h-16 ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>{/* ── end LEFT column ── */}
|
||||||
|
|
||||||
|
{/* ── RIGHT column: display ── */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Design</div>
|
||||||
{/* Service visibility */}
|
{/* Service visibility */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex items-start justify-between col-span-2 py-1">
|
<div className="flex items-start justify-between col-span-2 py-1">
|
||||||
@@ -507,33 +613,53 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onEditTypeStyle && form.type !== 'group' && form.type !== 'groupRect' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditTypeStyle((form.type ?? 'generic') as NodeType)}
|
||||||
|
className="flex items-center gap-1 self-start text-[10px] text-[#00d4ff] hover:underline"
|
||||||
|
>
|
||||||
|
<Palette size={10} /> Edit {NODE_TYPE_LABELS[form.type ?? 'generic']} style for all nodes on the canvas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Connection points per side (not for group containers) */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-2.5 col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<Label className="text-xs text-muted-foreground">Connection Points</Label>
|
||||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
{/* Spatial cross: each side's stepper sits where that side is. */}
|
||||||
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center justify-items-center gap-x-2 gap-y-2 py-1">
|
||||||
</div>
|
<div />
|
||||||
<input
|
<CPStepper label="Top" side="top" value={sideValue('top')}
|
||||||
type="range"
|
onChange={(v) => set('top_handles', v)} />
|
||||||
min={MIN_BOTTOM_HANDLES}
|
<div />
|
||||||
max={MAX_BOTTOM_HANDLES}
|
|
||||||
step={1}
|
<CPStepper label="Left" side="left" value={sideValue('left')}
|
||||||
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
onChange={(v) => set('left_handles', v)} />
|
||||||
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
<div
|
||||||
aria-label="Bottom connection points slider"
|
className="flex items-center justify-center rounded-md border text-[9px] uppercase tracking-wide font-medium select-none"
|
||||||
className="w-full accent-[#00d4ff] cursor-pointer"
|
style={{
|
||||||
/>
|
width: 64, height: 40,
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
borderColor: resolvedNodeColors.border,
|
||||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
background: `${resolvedNodeColors.background}`,
|
||||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
color: resolvedNodeColors.icon,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
node
|
||||||
|
</div>
|
||||||
|
<CPStepper label="Right" side="right" value={sideValue('right')}
|
||||||
|
onChange={(v) => set('right_handles', v)} />
|
||||||
|
|
||||||
|
<div />
|
||||||
|
<CPStepper label="Bottom" side="bottom" value={sideValue('bottom')}
|
||||||
|
onChange={(v) => set('bottom_handles', v)} />
|
||||||
|
<div />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between pt-1">
|
<div className="flex items-center justify-between pt-1">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
|
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
|
||||||
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
|
<span className="text-[10px] text-muted-foreground/60">Label each connection point</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -554,16 +680,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
</div>{/* ── end RIGHT column ── */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
|
||||||
<Input
|
|
||||||
value={form.notes ?? ''}
|
|
||||||
onChange={(e) => set('notes', e.target.value)}
|
|
||||||
placeholder="Optional notes"
|
|
||||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(screen.getByText('Default size')).toBeDefined()
|
expect(screen.getByText('Default size')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('initialNodeType preselects that type editor on open (NodeModal shortcut)', () => {
|
||||||
|
render(<CustomStyleModal open initialNodeType="switch" onClose={vi.fn()} />)
|
||||||
|
// Editor for Switch is shown immediately, no manual selection needed.
|
||||||
|
expect(screen.getByText(/Apply to existing Switch/)).toBeDefined()
|
||||||
|
expect(screen.queryByText(/Select a node type/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
@@ -135,6 +142,34 @@ describe('CustomStyleModal', () => {
|
|||||||
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows per-side default connection-point inputs in the node editor', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
expect(screen.getByText('Default connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Top default connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Left default connection points')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults per-side inputs to 1 (top/bottom) and 0 (left/right)', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
expect((screen.getByLabelText('Top default connection points') as HTMLInputElement).value).toBe('1')
|
||||||
|
expect((screen.getByLabelText('Left default connection points') as HTMLInputElement).value).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing a per-side default persists via setCustomStyle on Save', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved: vi.fn() })
|
||||||
|
const setCustomStyle = vi.spyOn(useThemeStore.getState(), 'setCustomStyle')
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('Left default connection points'), { target: { value: '3' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
|
||||||
|
expect(setCustomStyle).toHaveBeenCalled()
|
||||||
|
const saved = setCustomStyle.mock.calls.at(-1)?.[0]
|
||||||
|
expect(saved?.nodes.router?.leftHandles).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
||||||
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
||||||
// happen on the open-prop edge, not via Radix onOpenChange.
|
// happen on the open-prop edge, not via Radix onOpenChange.
|
||||||
|
|||||||
@@ -105,6 +105,21 @@ describe('NodeModal', () => {
|
|||||||
confirmSpy.mockRestore()
|
confirmSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Custom-style shortcut ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the type-style shortcut and calls onEditTypeStyle with the node type', () => {
|
||||||
|
const onEditTypeStyle = vi.fn()
|
||||||
|
renderModal({ initial: BASE, onEditTypeStyle })
|
||||||
|
const link = screen.getByRole('button', { name: /style for all nodes on the canvas/i })
|
||||||
|
fireEvent.click(link)
|
||||||
|
expect(onEditTypeStyle).toHaveBeenCalledWith('server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the shortcut when onEditTypeStyle is not provided', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByText(/style for all nodes on the canvas/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Label validation ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
@@ -435,57 +450,88 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Bottom connection points ───────────────────────────────────────────
|
// ── Connection points per side (issue #243) ────────────────────────────
|
||||||
|
|
||||||
it('shows Bottom Connection Points for server type', () => {
|
it('shows the Connection Points section for server type', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
|
expect(screen.getByText('Connection Points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Top connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Right connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Bottom connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Left connection points')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides Bottom Connection Points for groupRect', () => {
|
it('hides Connection Points for groupRect', () => {
|
||||||
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||||
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
expect(screen.queryByText('Connection Points')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides Bottom Connection Points for group', () => {
|
it('hides Connection Points for group', () => {
|
||||||
renderModal({ initial: { ...BASE, type: 'group' } })
|
renderModal({ initial: { ...BASE, type: 'group' } })
|
||||||
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
expect(screen.queryByText('Connection Points')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults bottom_handles to 1', () => {
|
it('defaults top/bottom to 1 and left/right to 0', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('1')
|
||||||
expect(slider.value).toBe('1')
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('1')
|
||||||
|
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).value).toBe('0')
|
||||||
|
expect((screen.getByLabelText('Right connection points') as HTMLInputElement).value).toBe('0')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills bottom_handles from initial', () => {
|
it('pre-fills each side from initial', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
renderModal({ initial: { ...BASE, top_handles: 2, bottom_handles: 3, left_handles: 4, right_handles: 1 } })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('2')
|
||||||
expect(slider.value).toBe('3')
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('3')
|
||||||
|
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).value).toBe('4')
|
||||||
|
expect((screen.getByLabelText('Right connection points') as HTMLInputElement).value).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits updated bottom_handles', () => {
|
it('submits per-side counts edited via the number inputs', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '12' } })
|
||||||
fireEvent.change(slider, { target: { value: '12' } })
|
fireEvent.change(screen.getByLabelText('Left connection points'), { target: { value: '3' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
const payload = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(payload.bottom_handles).toBe(12)
|
||||||
|
expect(payload.left_handles).toBe(3)
|
||||||
|
expect(payload.top_handles).toBe(1)
|
||||||
|
expect(payload.right_handles).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('supports the full 1..64 range (issue #20)', () => {
|
it('increments a side with the + stepper button', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
|
||||||
expect(slider.min).toBe('1')
|
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
|
||||||
expect(slider.max).toBe('64')
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
fireEvent.change(slider, { target: { value: '52' } })
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).right_handles).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the − button at the side minimum (0 for left/right, 1 for top/bottom)', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect((screen.getByRole('button', { name: 'Decrease Left connection points' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect((screen.getByRole('button', { name: 'Decrease Top connection points' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('left/right min is 0, top/bottom min is 1; max is 64 everywhere', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('1')
|
||||||
|
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).min).toBe('0')
|
||||||
|
for (const label of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||||
|
expect((screen.getByLabelText(`${label} connection points`) as HTMLInputElement).max).toBe('64')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the full range up to 64 (issue #20)', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '52' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps pre-filled out-of-range values into [1,64]', () => {
|
it('clamps pre-filled out-of-range values into range', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('64')
|
||||||
expect(slider.value).toBe('64')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
||||||
|
|||||||
@@ -1184,6 +1184,60 @@ describe('canvasStore', () => {
|
|||||||
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
|
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
|
||||||
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
|
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── per-side edge remapping (issue #243) ──────────────────────────────────
|
||||||
|
|
||||||
|
it('remaps top edges to "top" slot 0 when top_handles is reduced', () => {
|
||||||
|
const node = makeNode('n1', { top_handles: 3 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'top-3' }
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { top_handles: 1 })
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('top')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remaps left/right edges to "bottom" when the side drops to 0 (slot 0 gone)', () => {
|
||||||
|
const node = makeNode('n1', { left_handles: 2, right_handles: 2 })
|
||||||
|
const edges = [
|
||||||
|
{ ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'left' },
|
||||||
|
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'left-2' },
|
||||||
|
{ ...makeEdge('e3', 'n1', 'n2'), targetHandle: 'right', source: 'n2', target: 'n1' },
|
||||||
|
]
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { left_handles: 0, right_handles: 0 })
|
||||||
|
|
||||||
|
const after = useCanvasStore.getState().edges
|
||||||
|
expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom')
|
||||||
|
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom')
|
||||||
|
expect(after.find((e) => e.id === 'e3')?.targetHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remaps a side to its own slot 0 when shrinking but not to 0', () => {
|
||||||
|
const node = makeNode('n1', { right_handles: 3 })
|
||||||
|
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'right-3' }
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { right_handles: 1 })
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('right')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves edges on other sides untouched when one side shrinks', () => {
|
||||||
|
const node = makeNode('n1', { top_handles: 3, bottom_handles: 3 })
|
||||||
|
const edges = [
|
||||||
|
{ ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' },
|
||||||
|
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'top-2' },
|
||||||
|
]
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
|
||||||
|
|
||||||
|
const after = useCanvasStore.getState().edges
|
||||||
|
expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom')
|
||||||
|
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('top-2')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('canvasStore — custom style apply', () => {
|
describe('canvasStore — custom style apply', () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
|
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
|
||||||
@@ -357,22 +357,26 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
const children = nodes.filter((n) => !!n.parentId)
|
const children = nodes.filter((n) => !!n.parentId)
|
||||||
nodes = [...parents, ...children]
|
nodes = [...parents, ...children]
|
||||||
}
|
}
|
||||||
// Remap edges when bottom_handles is reduced so no edge disappears
|
// Remap edges when any side's handle count is reduced so no edge disappears.
|
||||||
|
// Removed handles fall back to the side's slot-0 id, or 'bottom' if the
|
||||||
|
// side dropped to 0 (its slot-0 id no longer exists).
|
||||||
let edges = state.edges
|
let edges = state.edges
|
||||||
if ('bottom_handles' in data && data.bottom_handles != null) {
|
const currentNode = state.nodes.find((n) => n.id === id)
|
||||||
const currentNode = state.nodes.find((n) => n.id === id)
|
for (const side of SIDES) {
|
||||||
const oldCount = currentNode?.data.bottom_handles ?? 1
|
const field = handleCountField(side)
|
||||||
const newCount = data.bottom_handles
|
if (!(field in data) || data[field] == null) continue
|
||||||
if (newCount < oldCount) {
|
const oldCount = currentNode?.data[field] ?? sideDefault(side)
|
||||||
const removed = removedBottomHandleIds(oldCount, newCount)
|
const newCount = data[field] as number
|
||||||
edges = state.edges.map((e) => {
|
if (newCount >= oldCount) continue
|
||||||
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
|
const removed = removedHandleIds(side, oldCount, newCount)
|
||||||
return { ...e, sourceHandle: 'bottom' }
|
const fallback = newCount === 0 ? 'bottom' : handleId(side, 0)
|
||||||
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
|
edges = edges.map((e) => {
|
||||||
return { ...e, targetHandle: 'bottom' }
|
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
|
||||||
return e
|
return { ...e, sourceHandle: fallback }
|
||||||
})
|
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
|
||||||
}
|
return { ...e, targetHandle: fallback }
|
||||||
|
return e
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, edges, hasUnsavedChanges: true }
|
return { nodes, edges, hasUnsavedChanges: true }
|
||||||
|
|||||||
@@ -140,9 +140,15 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
*/
|
*/
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
/** Number of bottom connection points, 1..64. Default 1 (centered). */
|
/** Number of top connection points, 0..64. Default 1. */
|
||||||
|
top_handles?: number
|
||||||
|
/** Number of bottom connection points, 0..64. Default 1 (centered). */
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
/** Show a port number (1..N) above each bottom connection point. */
|
/** Number of left connection points, 0..64. Default 0 (opt-in). */
|
||||||
|
left_handles?: number
|
||||||
|
/** Number of right connection points, 0..64. Default 0 (opt-in). */
|
||||||
|
right_handles?: number
|
||||||
|
/** Show a port number (1..N) next to each connection point. */
|
||||||
show_port_numbers?: boolean
|
show_port_numbers?: boolean
|
||||||
/** Text node content (type === 'text') */
|
/** Text node content (type === 'text') */
|
||||||
text_content?: string
|
text_content?: string
|
||||||
@@ -239,6 +245,11 @@ export interface NodeTypeStyle {
|
|||||||
iconOpacity: number
|
iconOpacity: number
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
/** Default connection-point counts per side for new nodes of this type. */
|
||||||
|
topHandles?: number
|
||||||
|
bottomHandles?: number
|
||||||
|
leftHandles?: number
|
||||||
|
rightHandles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeTypeStyle {
|
export interface EdgeTypeStyle {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
serializeEdge,
|
serializeEdge,
|
||||||
deserializeApiNode,
|
deserializeApiNode,
|
||||||
deserializeApiEdge,
|
deserializeApiEdge,
|
||||||
|
migrateClusterHandles,
|
||||||
type ApiNode,
|
type ApiNode,
|
||||||
type ApiEdge,
|
type ApiEdge,
|
||||||
} from '@/utils/canvasSerializer'
|
} from '@/utils/canvasSerializer'
|
||||||
@@ -305,6 +306,26 @@ describe('deserializeApiNode — regular node', () => {
|
|||||||
expect(result.height).toBeUndefined()
|
expect(result.height).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Backward-compat: pre-#243 saves have no top/left/right_handles fields.
|
||||||
|
it('defaults per-side handle counts for legacy nodes (top/bottom=1, left/right=0)', () => {
|
||||||
|
const result = deserializeApiNode(makeApiNode(), emptyMap)
|
||||||
|
expect(result.data.top_handles).toBe(1)
|
||||||
|
expect(result.data.bottom_handles).toBe(1)
|
||||||
|
expect(result.data.left_handles).toBe(0)
|
||||||
|
expect(result.data.right_handles).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips explicit per-side handle counts', () => {
|
||||||
|
const result = deserializeApiNode(
|
||||||
|
makeApiNode({ top_handles: 2, bottom_handles: 5, left_handles: 3, right_handles: 1 }),
|
||||||
|
emptyMap,
|
||||||
|
)
|
||||||
|
expect(result.data.top_handles).toBe(2)
|
||||||
|
expect(result.data.bottom_handles).toBe(5)
|
||||||
|
expect(result.data.left_handles).toBe(3)
|
||||||
|
expect(result.data.right_handles).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('sets parentId and extent for children of container proxmox', () => {
|
it('sets parentId and extent for children of container proxmox', () => {
|
||||||
const map = new Map([['px1', true]])
|
const map = new Map([['px1', true]])
|
||||||
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
|
||||||
@@ -463,3 +484,40 @@ describe('serializeNode — text node roundtrip', () => {
|
|||||||
expect(restored.data.label).toBe('Hello world')
|
expect(restored.data.label).toBe('Hello world')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── migrateClusterHandles (#243) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('migrateClusterHandles', () => {
|
||||||
|
it('remaps cluster-right/left edge handles to right/left slot-0', () => {
|
||||||
|
const nodes = [makeRfNode({ id: 'p1', type: 'proxmox' }), makeRfNode({ id: 'p2', type: 'proxmox' })]
|
||||||
|
const edges = [makeRfEdge({ id: 'c1', source: 'p1', target: 'p2', type: 'cluster', sourceHandle: 'cluster-right', targetHandle: 'cluster-left', data: { type: 'cluster' } })]
|
||||||
|
const out = migrateClusterHandles(nodes, edges)
|
||||||
|
const e = out.edges.find((x) => x.id === 'c1')!
|
||||||
|
expect(e.sourceHandle).toBe('right')
|
||||||
|
expect(e.targetHandle).toBe('left')
|
||||||
|
expect(e.type).toBe('cluster') // style/type preserved
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bumps the connected side to 1 on each proxmox node', () => {
|
||||||
|
const nodes = [makeRfNode({ id: 'p1', type: 'proxmox' }), makeRfNode({ id: 'p2', type: 'proxmox' })]
|
||||||
|
const edges = [makeRfEdge({ id: 'c1', source: 'p1', target: 'p2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' })]
|
||||||
|
const out = migrateClusterHandles(nodes, edges)
|
||||||
|
expect(out.nodes.find((n) => n.id === 'p1')!.data.right_handles).toBe(1)
|
||||||
|
expect(out.nodes.find((n) => n.id === 'p2')!.data.left_handles).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not lower an already-higher side count', () => {
|
||||||
|
const nodes = [makeRfNode({ id: 'p1', type: 'proxmox', data: { label: 'x', type: 'proxmox', status: 'online', services: [], right_handles: 3 } })]
|
||||||
|
const edges = [makeRfEdge({ id: 'c1', source: 'p1', target: 'p2', sourceHandle: 'cluster-right' })]
|
||||||
|
const out = migrateClusterHandles(nodes, edges)
|
||||||
|
expect(out.nodes[0].data.right_handles).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves non-cluster edges and their nodes untouched (identity when nothing to do)', () => {
|
||||||
|
const nodes = [makeRfNode({ id: 'p1' })]
|
||||||
|
const edges = [makeRfEdge({ id: 'e1', sourceHandle: 'bottom', targetHandle: 'top' })]
|
||||||
|
const out = migrateClusterHandles(nodes, edges)
|
||||||
|
expect(out.nodes).toBe(nodes)
|
||||||
|
expect(out.edges[0].sourceHandle).toBe('bottom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
MIN_BOTTOM_HANDLES,
|
MIN_BOTTOM_HANDLES,
|
||||||
MAX_BOTTOM_HANDLES,
|
MAX_BOTTOM_HANDLES,
|
||||||
|
MAX_HANDLES,
|
||||||
bottomHandleId,
|
bottomHandleId,
|
||||||
bottomHandlePositions,
|
bottomHandlePositions,
|
||||||
clampBottomHandles,
|
clampBottomHandles,
|
||||||
normalizeHandle,
|
normalizeHandle,
|
||||||
removedBottomHandleIds,
|
removedBottomHandleIds,
|
||||||
|
handleId,
|
||||||
|
handlePositions,
|
||||||
|
clampHandles,
|
||||||
|
removedHandleIds,
|
||||||
|
sideDefault,
|
||||||
|
handleCountField,
|
||||||
|
isVerticalSide,
|
||||||
|
SIDES,
|
||||||
} from '../handleUtils'
|
} from '../handleUtils'
|
||||||
|
|
||||||
describe('bottomHandleId', () => {
|
describe('bottomHandleId', () => {
|
||||||
@@ -176,3 +185,125 @@ describe('removedBottomHandleIds', () => {
|
|||||||
expect(removed.has('bottom-10')).toBe(true)
|
expect(removed.has('bottom-10')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Side-generic API (issue #243) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('sideDefault', () => {
|
||||||
|
it('top/bottom default to 1 (backward-compatible)', () => {
|
||||||
|
expect(sideDefault('top')).toBe(1)
|
||||||
|
expect(sideDefault('bottom')).toBe(1)
|
||||||
|
})
|
||||||
|
it('left/right default to 0 (opt-in, no visual change to old diagrams)', () => {
|
||||||
|
expect(sideDefault('left')).toBe(0)
|
||||||
|
expect(sideDefault('right')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleCountField', () => {
|
||||||
|
it('maps each side to its NodeData field', () => {
|
||||||
|
expect(handleCountField('top')).toBe('top_handles')
|
||||||
|
expect(handleCountField('bottom')).toBe('bottom_handles')
|
||||||
|
expect(handleCountField('left')).toBe('left_handles')
|
||||||
|
expect(handleCountField('right')).toBe('right_handles')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleId', () => {
|
||||||
|
it('slot 0 is the bare side name for every side', () => {
|
||||||
|
expect(handleId('top', 0)).toBe('top')
|
||||||
|
expect(handleId('bottom', 0)).toBe('bottom')
|
||||||
|
expect(handleId('left', 0)).toBe('left')
|
||||||
|
expect(handleId('right', 0)).toBe('right')
|
||||||
|
})
|
||||||
|
it('slot N ≥ 1 follows side-N pattern (1-indexed shift)', () => {
|
||||||
|
expect(handleId('top', 1)).toBe('top-2')
|
||||||
|
expect(handleId('left', 2)).toBe('left-3')
|
||||||
|
expect(handleId('right', 47)).toBe('right-48')
|
||||||
|
})
|
||||||
|
it('bottom matches the legacy alias', () => {
|
||||||
|
for (let i = 0; i < 5; i++) expect(handleId('bottom', i)).toBe(bottomHandleId(i))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clampHandles', () => {
|
||||||
|
it('min is the side default (0 for L/R, 1 for T/B)', () => {
|
||||||
|
expect(clampHandles('top', 0)).toBe(1)
|
||||||
|
expect(clampHandles('bottom', -3)).toBe(1)
|
||||||
|
expect(clampHandles('left', -3)).toBe(0)
|
||||||
|
expect(clampHandles('right', 0)).toBe(0)
|
||||||
|
})
|
||||||
|
it('max is 64 for every side', () => {
|
||||||
|
expect(clampHandles('top', 9999)).toBe(MAX_HANDLES)
|
||||||
|
expect(clampHandles('left', 65)).toBe(64)
|
||||||
|
})
|
||||||
|
it('non-finite / non-number falls back to side min', () => {
|
||||||
|
expect(clampHandles('left', NaN)).toBe(0)
|
||||||
|
expect(clampHandles('top', undefined)).toBe(1)
|
||||||
|
expect(clampHandles('right', '4' as unknown)).toBe(0)
|
||||||
|
})
|
||||||
|
it('floors fractional values', () => {
|
||||||
|
expect(clampHandles('left', 3.9)).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handlePositions', () => {
|
||||||
|
it('horizontal sides reuse the bottom distribution', () => {
|
||||||
|
expect(handlePositions('top', 3)).toEqual([20, 50, 80])
|
||||||
|
expect(handlePositions('bottom', 4)).toEqual([15, 38, 62, 85])
|
||||||
|
})
|
||||||
|
it('vertical sides use the same offsets (applied to the top axis)', () => {
|
||||||
|
expect(handlePositions('left', 2)).toEqual([25, 75])
|
||||||
|
expect(handlePositions('right', 1)).toEqual([50])
|
||||||
|
})
|
||||||
|
it('count 0 yields no handles (only reachable for L/R)', () => {
|
||||||
|
expect(handlePositions('left', 0)).toEqual([])
|
||||||
|
expect(handlePositions('right', 0)).toEqual([])
|
||||||
|
})
|
||||||
|
it('top/bottom clamp 0 up to 1 (never empty)', () => {
|
||||||
|
expect(handlePositions('top', 0)).toEqual([50])
|
||||||
|
expect(handlePositions('bottom', 0)).toEqual([50])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isVerticalSide', () => {
|
||||||
|
it('left/right are vertical; top/bottom are not', () => {
|
||||||
|
expect(isVerticalSide('left')).toBe(true)
|
||||||
|
expect(isVerticalSide('right')).toBe(true)
|
||||||
|
expect(isVerticalSide('top')).toBe(false)
|
||||||
|
expect(isVerticalSide('bottom')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('normalizeHandle — all sides', () => {
|
||||||
|
it('maps left-t / right-t and their -N variants to source ids', () => {
|
||||||
|
expect(normalizeHandle('left-t')).toBe('left')
|
||||||
|
expect(normalizeHandle('right-t')).toBe('right')
|
||||||
|
expect(normalizeHandle('left-2-t')).toBe('left-2')
|
||||||
|
expect(normalizeHandle('right-48-t')).toBe('right-48')
|
||||||
|
expect(normalizeHandle('top-3-t')).toBe('top-3')
|
||||||
|
})
|
||||||
|
it('passes through source ids for every side', () => {
|
||||||
|
expect(normalizeHandle('left')).toBe('left')
|
||||||
|
expect(normalizeHandle('right-2')).toBe('right-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removedHandleIds — all sides', () => {
|
||||||
|
it('left 3 → 0 removes left, left-2, left-3', () => {
|
||||||
|
expect(removedHandleIds('left', 3, 0)).toEqual(new Set(['left', 'left-2', 'left-3']))
|
||||||
|
})
|
||||||
|
it('top 2 → 1 removes only top-2, keeps slot 0', () => {
|
||||||
|
const removed = removedHandleIds('top', 2, 1)
|
||||||
|
expect(removed).toEqual(new Set(['top-2']))
|
||||||
|
expect(removed.has('top')).toBe(false)
|
||||||
|
})
|
||||||
|
it('bottom matches the legacy alias', () => {
|
||||||
|
expect(removedHandleIds('bottom', 4, 1)).toEqual(removedBottomHandleIds(4, 1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SIDES', () => {
|
||||||
|
it('lists all four sides', () => {
|
||||||
|
expect([...SIDES].sort()).toEqual(['bottom', 'left', 'right', 'top'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ describe('parseYamlToCanvas', () => {
|
|||||||
expect(edges[0].data?.type).toBe('fibre')
|
expect(edges[0].data?.type).toBe('fibre')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cluster edges have cluster-right→cluster-left handles', () => {
|
it('cluster edges are remapped onto right→left connection points (#243)', () => {
|
||||||
const yaml = `
|
const yaml = `
|
||||||
- nodeType: proxmox
|
- nodeType: proxmox
|
||||||
label: "PVE1"
|
label: "PVE1"
|
||||||
@@ -115,10 +115,15 @@ describe('parseYamlToCanvas', () => {
|
|||||||
- nodeType: proxmox
|
- nodeType: proxmox
|
||||||
label: "PVE2"
|
label: "PVE2"
|
||||||
`
|
`
|
||||||
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
expect(edges).toHaveLength(1)
|
expect(edges).toHaveLength(1)
|
||||||
expect(edges[0].sourceHandle).toBe('cluster-right')
|
expect(edges[0].sourceHandle).toBe('right')
|
||||||
expect(edges[0].targetHandle).toBe('cluster-left')
|
expect(edges[0].targetHandle).toBe('left')
|
||||||
|
// Connected sides get a connection point so the link is anchored.
|
||||||
|
const src = nodes.find((n) => n.id === edges[0].source)!
|
||||||
|
const tgt = nodes.find((n) => n.id === edges[0].target)!
|
||||||
|
expect(src.data.right_handles).toBe(1)
|
||||||
|
expect(tgt.data.left_handles).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parent relationship sets parentId and creates an edge', () => {
|
it('parent relationship sets parentId and creates an edge', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||||
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,7 +31,10 @@ export interface ApiNode extends Record<string, unknown> {
|
|||||||
properties?: unknown[] | null
|
properties?: unknown[] | null
|
||||||
width?: number | null
|
width?: number | null
|
||||||
height?: number | null
|
height?: number | null
|
||||||
|
top_handles?: number
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
|
left_handles?: number
|
||||||
|
right_handles?: number
|
||||||
show_port_numbers?: boolean
|
show_port_numbers?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +120,10 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
// fractional content-fit value.
|
// fractional content-fit value.
|
||||||
width: n.width ?? n.measured?.width ?? null,
|
width: n.width ?? n.measured?.width ?? null,
|
||||||
height: n.height ?? n.measured?.height ?? null,
|
height: n.height ?? n.measured?.height ?? null,
|
||||||
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
top_handles: clampHandles('top', n.data.top_handles ?? 1),
|
||||||
|
bottom_handles: clampHandles('bottom', n.data.bottom_handles ?? 1),
|
||||||
|
left_handles: clampHandles('left', n.data.left_handles ?? 0),
|
||||||
|
right_handles: clampHandles('right', n.data.right_handles ?? 0),
|
||||||
show_port_numbers: n.data.show_port_numbers ?? false,
|
show_port_numbers: n.data.show_port_numbers ?? false,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
@@ -178,7 +184,10 @@ export function deserializeApiNode(
|
|||||||
data: {
|
data: {
|
||||||
...n,
|
...n,
|
||||||
type: normalizedType,
|
type: normalizedType,
|
||||||
bottom_handles: clampBottomHandles(n.bottom_handles ?? 1),
|
top_handles: clampHandles('top', n.top_handles ?? 1),
|
||||||
|
bottom_handles: clampHandles('bottom', n.bottom_handles ?? 1),
|
||||||
|
left_handles: clampHandles('left', n.left_handles ?? 0),
|
||||||
|
right_handles: clampHandles('right', n.right_handles ?? 0),
|
||||||
collapsed: Boolean(n.custom_colors?.collapsed),
|
collapsed: Boolean(n.custom_colors?.collapsed),
|
||||||
} as unknown as NodeData,
|
} as unknown as NodeData,
|
||||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||||
@@ -207,3 +216,52 @@ export function deserializeApiEdge(e: ApiEdge): Edge<EdgeData> {
|
|||||||
data: e as unknown as EdgeData,
|
data: e as unknown as EdgeData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy Proxmox nodes had two always-on cluster handles ('cluster-left' /
|
||||||
|
// 'cluster-right'). Those are gone — cluster links now use the normal, per-side
|
||||||
|
// connection points. On load we remap any edge still bound to a cluster handle
|
||||||
|
// onto the matching left/right slot-0 handle and give that node's side a
|
||||||
|
// connection point (count → at least 1) so the link survives. The edge's
|
||||||
|
// 'cluster' type/colour is untouched.
|
||||||
|
const CLUSTER_HANDLE_SIDE: Record<string, Side> = {
|
||||||
|
'cluster-left': 'left',
|
||||||
|
'cluster-right': 'right',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateClusterHandles(
|
||||||
|
nodes: Node<NodeData>[],
|
||||||
|
edges: Edge<EdgeData>[],
|
||||||
|
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } {
|
||||||
|
// nodeId → sides that need at least one connection point after remap.
|
||||||
|
const needed = new Map<string, Set<Side>>()
|
||||||
|
const mark = (id: string, side: Side) => {
|
||||||
|
const set = needed.get(id) ?? new Set<Side>()
|
||||||
|
set.add(side)
|
||||||
|
needed.set(id, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
const migratedEdges = edges.map((e) => {
|
||||||
|
const srcSide = e.sourceHandle ? CLUSTER_HANDLE_SIDE[e.sourceHandle] : undefined
|
||||||
|
const tgtSide = e.targetHandle ? CLUSTER_HANDLE_SIDE[e.targetHandle] : undefined
|
||||||
|
if (!srcSide && !tgtSide) return e
|
||||||
|
const next = { ...e }
|
||||||
|
if (srcSide) { next.sourceHandle = handleId(srcSide, 0); mark(e.source, srcSide) }
|
||||||
|
if (tgtSide) { next.targetHandle = handleId(tgtSide, 0); mark(e.target, tgtSide) }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
if (needed.size === 0) return { nodes, edges: migratedEdges }
|
||||||
|
|
||||||
|
const migratedNodes = nodes.map((n) => {
|
||||||
|
const sides = needed.get(n.id)
|
||||||
|
if (!sides) return n
|
||||||
|
const data: NodeData = { ...n.data }
|
||||||
|
for (const side of sides) {
|
||||||
|
const field = handleCountField(side)
|
||||||
|
data[field] = Math.max((data[field] as number | undefined) ?? 0, 1)
|
||||||
|
}
|
||||||
|
return { ...n, data }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { nodes: migratedNodes, edges: migratedEdges }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,39 +1,73 @@
|
|||||||
/**
|
/**
|
||||||
* Bottom handle configuration for multi-handle nodes.
|
* Per-side connection-point (React Flow Handle) configuration.
|
||||||
*
|
*
|
||||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
* Handle IDs: slot 0 = the bare side name ('top' | 'bottom' | 'left' | 'right')
|
||||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
|
* slot N ≥ 1 = '${side}-${N + 1}' (e.g. 'bottom-2', 'left-3', 'top-64')
|
||||||
|
*
|
||||||
|
* Slot 0 keeps the bare name so edges saved before the multi-handle expansion
|
||||||
|
* (which reference 'top' / 'bottom') stay valid — full backward compatibility.
|
||||||
*
|
*
|
||||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
|
* 'bottom-t', 'bottom-2-t', 'left-t', 'right-3-t', ...
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MIN_BOTTOM_HANDLES = 1
|
import type { NodeData } from '@/types'
|
||||||
export const MAX_BOTTOM_HANDLES = 64
|
|
||||||
|
|
||||||
/** Returns the source handle ID at a given slot index. */
|
export type Side = 'top' | 'bottom' | 'left' | 'right'
|
||||||
export function bottomHandleId(idx: number): string {
|
|
||||||
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
export const SIDES: readonly Side[] = ['top', 'bottom', 'left', 'right']
|
||||||
|
|
||||||
|
export const MAX_HANDLES = 64
|
||||||
|
|
||||||
|
// Back-compat aliases (bottom was the original, only, multi-handle side).
|
||||||
|
export const MIN_BOTTOM_HANDLES = 1
|
||||||
|
export const MAX_BOTTOM_HANDLES = MAX_HANDLES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum (and default) count for a side.
|
||||||
|
* Top/Bottom default to 1 (their historical single handle); Left/Right default
|
||||||
|
* to 0 so existing diagrams gain no side handles unless the user opts in.
|
||||||
|
*/
|
||||||
|
export function sideDefault(side: Side): number {
|
||||||
|
return side === 'top' || side === 'bottom' ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
|
/** The NodeData field name that stores a side's handle count. */
|
||||||
export function clampBottomHandles(n: unknown): number {
|
export function handleCountField(side: Side): 'top_handles' | 'bottom_handles' | 'left_handles' | 'right_handles' {
|
||||||
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
|
return `${side}_handles` as 'top_handles' | 'bottom_handles' | 'left_handles' | 'right_handles'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved connection-point count for a side (missing field → side default). */
|
||||||
|
export function sideHandleCount(data: NodeData, side: Side): number {
|
||||||
|
return clampHandles(side, data[handleCountField(side)] ?? sideDefault(side))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the source handle ID at a given slot index for a side. */
|
||||||
|
export function handleId(side: Side, idx: number): string {
|
||||||
|
return idx === 0 ? side : `${side}-${idx + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a raw count into the supported range for a side (min = sideDefault, max = 64). */
|
||||||
|
export function clampHandles(side: Side, n: unknown): number {
|
||||||
|
const min = sideDefault(side)
|
||||||
|
if (typeof n !== 'number' || !Number.isFinite(n)) return min
|
||||||
const i = Math.floor(n)
|
const i = Math.floor(n)
|
||||||
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
|
if (i < min) return min
|
||||||
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
|
if (i > MAX_HANDLES) return MAX_HANDLES
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left % positions for each handle slot.
|
* Percentage offsets for each handle slot along the side's axis.
|
||||||
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
|
* Horizontal sides (top/bottom) → left %, vertical sides (left/right) → top %.
|
||||||
|
* Counts 1..4 keep the original hand-tuned values to preserve exact pixel
|
||||||
* positions on canvases saved before the multi-handle expansion.
|
* positions on canvases saved before the multi-handle expansion.
|
||||||
* Counts ≥ 5 use uniform spacing.
|
* Counts ≥ 5 use uniform spacing. Count 0 → [].
|
||||||
*/
|
*/
|
||||||
export function bottomHandlePositions(count: number): number[] {
|
export function handlePositions(side: Side, count: number): number[] {
|
||||||
const c = clampBottomHandles(count)
|
const c = clampHandles(side, count)
|
||||||
switch (c) {
|
switch (c) {
|
||||||
|
case 0: return []
|
||||||
case 1: return [50]
|
case 1: return [50]
|
||||||
case 2: return [25, 75]
|
case 2: return [25, 75]
|
||||||
case 3: return [20, 50, 80]
|
case 3: return [20, 50, 80]
|
||||||
@@ -42,28 +76,56 @@ export function bottomHandlePositions(count: number): number[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when a side lays out its handles vertically (offset is a top %). */
|
||||||
|
export function isVerticalSide(side: Side): boolean {
|
||||||
|
return side === 'left' || side === 'right'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a raw handle ID coming from a React Flow connection event.
|
* Normalize a raw handle ID coming from a React Flow connection event.
|
||||||
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source
|
* Invisible target handles (e.g. 'bottom-2-t', 'left-t') are mapped to their
|
||||||
* counterpart ('bottom-2') so the stored edge ID is stable and consistent.
|
* source counterpart ('bottom-2', 'left') so the stored edge ID is stable.
|
||||||
*/
|
*/
|
||||||
export function normalizeHandle(h: string | null | undefined): string | null {
|
export function normalizeHandle(h: string | null | undefined): string | null {
|
||||||
if (!h) return null
|
if (!h) return null
|
||||||
if (h === 'top-t') return 'top'
|
const m = h.match(/^((?:top|bottom|left|right)(?:-\d+)?)-t$/)
|
||||||
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
|
|
||||||
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
|
|
||||||
if (m) return m[1]
|
if (m) return m[1]
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the set of handle IDs that are removed when bottom_handles
|
* Returns the set of source handle IDs removed when a side's count is reduced
|
||||||
* is reduced from `oldCount` to `newCount`.
|
* from `oldCount` to `newCount`.
|
||||||
*/
|
*/
|
||||||
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
export function removedHandleIds(side: Side, oldCount: number, newCount: number): Set<string> {
|
||||||
const removed = new Set<string>()
|
const removed = new Set<string>()
|
||||||
for (let i = newCount; i < oldCount; i++) {
|
for (let i = newCount; i < oldCount; i++) {
|
||||||
removed.add(bottomHandleId(i))
|
removed.add(handleId(side, i))
|
||||||
}
|
}
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deprecated bottom-only aliases — kept so existing call sites don't churn.
|
||||||
|
// Prefer the side-generic functions above.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @deprecated use handleId('bottom', idx) */
|
||||||
|
export function bottomHandleId(idx: number): string {
|
||||||
|
return handleId('bottom', idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use clampHandles('bottom', n) */
|
||||||
|
export function clampBottomHandles(n: unknown): number {
|
||||||
|
return clampHandles('bottom', n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use handlePositions('bottom', count) */
|
||||||
|
export function bottomHandlePositions(count: number): number[] {
|
||||||
|
return handlePositions('bottom', count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use removedHandleIds('bottom', oldCount, newCount) */
|
||||||
|
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||||
|
return removedHandleIds('bottom', oldCount, newCount)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { migrateClusterHandles } from '@/utils/canvasSerializer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas.
|
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas.
|
||||||
@@ -170,5 +171,9 @@ export function parseYamlToCanvas(
|
|||||||
const mergedEdges = [...existingEdges, ...newEdges]
|
const mergedEdges = [...existingEdges, ...newEdges]
|
||||||
const laidOut = applyDagreLayout(mergedNodes, mergedEdges)
|
const laidOut = applyDagreLayout(mergedNodes, mergedEdges)
|
||||||
|
|
||||||
return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length }
|
// Cluster links are imported on the legacy 'cluster-left/right' handles;
|
||||||
|
// remap them to the per-side connection points (and give the side a point).
|
||||||
|
const migrated = migrateClusterHandles(laidOut, mergedEdges)
|
||||||
|
|
||||||
|
return { nodes: migrated.nodes, edges: migrated.edges, imported: newNodes.length }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user