From b708a08fd01d9a209c700cd4dc2c3d2c0ab21c7a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 3 Jul 2026 21:25:14 +0200 Subject: [PATCH] feat: customizable connection points per side (issue #243) Generalize the bottom-only handle machinery to all four sides. Top/Bottom keep their single-handle default; Left/Right are opt-in (default 0) so existing diagrams render unchanged. Handle IDs stay keyed off the bare side name at slot 0, keeping saved edges valid. - handleUtils: side-generic API (handleId, handlePositions, clampHandles, sideDefault, removedHandleIds, sideHandleCount); legacy bottom fns kept as delegating aliases - BaseNode + ProxmoxGroupNode render all sides via new shared SideHandles - updateNode remaps edges on any side's shrink (fallback to slot-0 / bottom) - serializer round-trips top/left/right_handles - NodeModal: spatial cross of -/N/+ steppers around a node preview, replacing the four stacked sliders; seeds per-type defaults - CustomStyleModal: per-type default connection points per side ha-relevant: yes --- .../canvas/__tests__/BaseNode.test.tsx | 30 ++-- .../src/components/canvas/nodes/BaseNode.tsx | 73 ++++------ .../canvas/nodes/ProxmoxGroupNode.tsx | 35 +---- .../components/canvas/nodes/SideHandles.tsx | 84 +++++++++++ .../components/modals/CustomStyleModal.tsx | 28 ++++ frontend/src/components/modals/NodeModal.tsx | 134 +++++++++++++++--- .../__tests__/CustomStyleModal.test.tsx | 28 ++++ .../modals/__tests__/NodeModal.test.tsx | 83 +++++++---- .../src/stores/__tests__/canvasStore.test.ts | 54 +++++++ frontend/src/stores/canvasStore.ts | 36 ++--- frontend/src/types/index.ts | 15 +- .../utils/__tests__/canvasSerializer.test.ts | 20 +++ .../src/utils/__tests__/handleUtils.test.ts | 131 +++++++++++++++++ frontend/src/utils/canvasSerializer.ts | 15 +- frontend/src/utils/handleUtils.ts | 118 +++++++++++---- 15 files changed, 701 insertions(+), 183 deletions(-) create mode 100644 frontend/src/components/canvas/nodes/SideHandles.tsx diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index fa42379..038fc29 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -9,7 +9,7 @@ let mockZoom = 1 vi.mock('@xyflow/react', () => ({ Handle: () => null, - Position: { Top: 'top', Bottom: 'bottom' }, + Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' }, NodeResizer: () => null, useUpdateNodeInternals: () => vi.fn(), useViewport: () => ({ zoom: mockZoom }), @@ -59,14 +59,9 @@ vi.mock('@/utils/propertyIcons', () => ({ resolvePropertyIcon: (icon: string | null) => icon ? Server : null, })) -vi.mock('@/utils/handleUtils', () => ({ - bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, - bottomHandlePositions: (count: number) => { - 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, -})) +// handleUtils is pure — use the real implementation so the side-generic API +// (SIDES, handleId, handlePositions, sideHandleCount, …) stays in sync. +vi.mock('@/utils/handleUtils', async (importOriginal) => await importOriginal()) beforeEach(() => { mockZoom = 1 }) @@ -176,15 +171,23 @@ describe('BaseNode — properties rendering', () => { }) }) -describe('BaseNode — port numbers (issue #20)', () => { - it('renders a number above each bottom handle when show_port_numbers is on', () => { +describe('BaseNode — port numbers (issue #20 / #243)', () => { + 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 }) - 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('3')).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', () => { renderBaseNode({ bottom_handles: 4 }) expect(screen.queryByText('1')).toBeNull() @@ -192,8 +195,9 @@ describe('BaseNode — port numbers (issue #20)', () => { }) 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 }) - expect(screen.getByText('1')).toBeDefined() + expect(screen.getAllByText('1')).toHaveLength(2) expect(screen.getByText('2')).toBeDefined() expect(screen.queryByText('3')).toBeNull() }) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index d3f356b..bce6d6f 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,5 +1,5 @@ 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 type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' @@ -10,7 +10,8 @@ import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore' 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' interface BaseNodeProps extends NodeProps> { @@ -24,7 +25,7 @@ function formatStorage(gb: number): string { export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) { 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 borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom]) @@ -47,6 +48,12 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const showLegacyHardware = !data.properties && data.show_hardware && (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 (
- - {/* Status dot — absolute to avoid affecting node auto-width */}
)} - {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { - const sourceId = bottomHandleId(idx) - const targetId = `${sourceId}-t` - return ( - - {data.show_port_numbers && ( - - {idx + 1} - - )} - - - - ) - })} +
) } diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index acbf241..2c01e99 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -10,13 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore' import { maskIp, splitIps } from '@/utils/maskIp' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' -import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils' import { BaseNode } from './BaseNode' +import { SideHandles } from './SideHandles' export function ProxmoxGroupNode(props: NodeProps>) { const { id, data, selected } = props 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 hideIp = useCanvasStore((s) => s.hideIp) @@ -145,33 +145,12 @@ export function ProxmoxGroupNode(props: NodeProps>) {
- - - {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { - const sourceId = bottomHandleId(idx) - const targetId = `${sourceId}-t` - return ( - - - - - ) - })} {/* Cluster handles */} = { + 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 ( + + {showLabels && data.show_port_numbers && ( + + {idx + 1} + + )} + + + + ) + }) + })} + + ) +} diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index c4d962f..1d6e5ea 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' +import { clampHandles, sideDefault } from '@/utils/handleUtils' import { THEMES } from '@/utils/themes' import { applyOpacity } from '@/utils/colorUtils' import type { @@ -178,6 +179,33 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
+
+
Default connection points
+
New {NODE_TYPE_LABELS[nodeType]} nodes start with these (0–64 per side)
+
+ {([ + ['Top', 'top', 'topHandles'], + ['Right', 'right', 'rightHandles'], + ['Bottom', 'bottom', 'bottomHandles'], + ['Left', 'left', 'leftHandles'], + ] as const).map(([label, side, key]) => ( +
+ {label} + 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]" + /> +
+ ))} +
+
+ + 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" + /> + + + {belowLabel && labelEl} + + ) +} + const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host'] const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] @@ -94,6 +154,16 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const set = (key: keyof NodeData, value: unknown) => 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) => { e.preventDefault() if (!form.label?.trim()) { @@ -113,8 +183,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const parent = parentCandidates.find((n) => n.id === safeParentId) if (!parent || !isValidParent(parent)) safeParentId = undefined } + const isGroupType = selectedType === 'groupRect' || selectedType === 'group' onSubmit({ ...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, container_mode: canUseContainerMode ? !!form.container_mode : false, }) @@ -509,31 +588,42 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' - {/* Bottom connection points (not for group containers) */} + {/* Connection points per side (not for group containers) */} {form.type !== 'groupRect' && form.type !== 'group' && ( -
-
- - {clampBottomHandles(form.bottom_handles ?? 1)} -
- set('bottom_handles', clampBottomHandles(Number(e.target.value)))} - aria-label="Bottom connection points slider" - className="w-full accent-[#00d4ff] cursor-pointer" - /> -
- {MIN_BOTTOM_HANDLES} - {MAX_BOTTOM_HANDLES} +
+ + {/* Spatial cross: each side's stepper sits where that side is. */} +
+
+ set('top_handles', v)} /> +
+ + set('left_handles', v)} /> +
+ node +
+ set('right_handles', v)} /> + +
+ set('bottom_handles', v)} /> +
- Label each bottom connection point + Label each connection point