diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 3ffb576..ae5591f 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({ })) vi.mock('@/utils/handleUtils', () => ({ - BOTTOM_HANDLE_IDS: ['bottom'], - BOTTOM_HANDLE_POSITIONS: { 1: [50] }, + bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, + bottomHandlePositions: () => [50], + clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1, })) beforeEach(() => { mockZoom = 1 }) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index d63d16d..c239452 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' import { maskIp, splitIps } from '@/utils/maskIp' -import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils' +import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' interface BaseNodeProps extends NodeProps> { icon: LucideIcon @@ -56,7 +56,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44` : 'none', opacity: data.status === 'offline' ? 0.55 : 1, - minWidth: 140, + // Grow node width when many bottom handles so each stays clickable (~14px slot). + minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14), width: width ? '100%' : undefined, height: height ? '100%' : undefined, }} @@ -173,9 +174,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )} - {(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => { - const sourceId = BOTTOM_HANDLE_IDS[idx] - const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t` + {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { + const sourceId = bottomHandleId(idx) + const targetId = `${sourceId}-t` return ( - - +
+ + {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} +
)} diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index c561495..147f14d 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -367,18 +367,37 @@ describe('NodeModal', () => { it('defaults bottom_handles to 1', () => { renderModal({ initial: BASE }) - expect(selects()[2].value).toBe('1') + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.value).toBe('1') }) it('pre-fills bottom_handles from initial', () => { renderModal({ initial: { ...BASE, bottom_handles: 3 } }) - expect(selects()[2].value).toBe('3') + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.value).toBe('3') }) it('submits updated bottom_handles', () => { const { onSubmit } = renderModal({ initial: BASE }) - fireEvent.change(selects()[2], { target: { value: '4' } }) + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + fireEvent.change(slider, { target: { value: '12' } }) fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect((onSubmit.mock.calls[0][0] as Partial).bottom_handles).toBe(4) + expect((onSubmit.mock.calls[0][0] as Partial).bottom_handles).toBe(12) + }) + + it('supports the full 1..48 range', () => { + const { onSubmit } = renderModal({ initial: BASE }) + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.min).toBe('1') + expect(slider.max).toBe('48') + fireEvent.change(slider, { target: { value: '48' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect((onSubmit.mock.calls[0][0] as Partial).bottom_handles).toBe(48) + }) + + it('clamps pre-filled out-of-range values into [1,48]', () => { + renderModal({ initial: { ...BASE, bottom_handles: 9999 } }) + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.value).toBe('48') }) }) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 893ccc9..2db5673 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -721,6 +721,24 @@ describe('canvasStore', () => { const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.sourceHandle).toBe('bottom') }) + + // Regression: handle cap raised from 4 to 48 — remap must scale. + it('remaps high-count handles when shrinking from 12 to 2', () => { + const node = makeNode('n1', { bottom_handles: 12 }) + const edges = [ + { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' }, + { ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' }, + { ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' }, + ] + useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) + + useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 }) + + const after = useCanvasStore.getState().edges + expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2') + expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom') + expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom') + }) }) describe('canvasStore — custom style apply', () => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2511a24..7b326fd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,6 +93,7 @@ export interface NodeData extends Record { height?: number } custom_icon?: string + /** Number of bottom connection points, 1..48. Default 1 (centered). */ bottom_handles?: number } diff --git a/frontend/src/utils/__tests__/handleUtils.test.ts b/frontend/src/utils/__tests__/handleUtils.test.ts index f5a9886..64d25ea 100644 --- a/frontend/src/utils/__tests__/handleUtils.test.ts +++ b/frontend/src/utils/__tests__/handleUtils.test.ts @@ -1,51 +1,105 @@ import { describe, it, expect } from 'vitest' import { - BOTTOM_HANDLE_IDS, - BOTTOM_HANDLE_POSITIONS, + MIN_BOTTOM_HANDLES, + MAX_BOTTOM_HANDLES, + bottomHandleId, + bottomHandlePositions, + clampBottomHandles, normalizeHandle, removedBottomHandleIds, } from '../handleUtils' -describe('BOTTOM_HANDLE_IDS', () => { +describe('bottomHandleId', () => { it('first id is always "bottom" for backward compatibility', () => { - expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom') + expect(bottomHandleId(0)).toBe('bottom') }) - it('has ids for 1–4 handles', () => { - expect(BOTTOM_HANDLE_IDS).toHaveLength(4) - expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4']) + it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => { + expect(bottomHandleId(1)).toBe('bottom-2') + expect(bottomHandleId(2)).toBe('bottom-3') + expect(bottomHandleId(3)).toBe('bottom-4') + expect(bottomHandleId(11)).toBe('bottom-12') + expect(bottomHandleId(47)).toBe('bottom-48') }) }) -describe('BOTTOM_HANDLE_POSITIONS', () => { +describe('clampBottomHandles', () => { + it('clamps below MIN to MIN', () => { + expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES) + }) + + it('clamps above MAX to MAX', () => { + expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES) + expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES) + }) + + it('returns MIN for non-finite or non-number', () => { + expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES) + }) + + it('floors fractional values', () => { + expect(clampBottomHandles(3.9)).toBe(3) + }) + + it('passes valid integers through', () => { + expect(clampBottomHandles(1)).toBe(1) + expect(clampBottomHandles(24)).toBe(24) + expect(clampBottomHandles(48)).toBe(48) + }) +}) + +describe('bottomHandlePositions — backward-compat lock for 1..4', () => { + // These exact arrays are the pre-multi-handle hand-tuned positions. + // Existing user canvases depend on them — do NOT change. it('1 handle is centered at 50%', () => { - expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50]) + expect(bottomHandlePositions(1)).toEqual([50]) }) - it('2 handles are symmetric', () => { - const [a, b] = BOTTOM_HANDLE_POSITIONS[2] - expect(a).toBeLessThan(50) - expect(b).toBeGreaterThan(50) - expect(a + b).toBe(100) + it('2 handles use exact prior positions', () => { + expect(bottomHandlePositions(2)).toEqual([25, 75]) }) - it('3 handles include a center at 50%', () => { - expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50) - expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3) + it('3 handles use exact prior positions', () => { + expect(bottomHandlePositions(3)).toEqual([20, 50, 80]) }) - it('4 handles are evenly spaced', () => { - const pos = BOTTOM_HANDLE_POSITIONS[4] - expect(pos).toHaveLength(4) - // All values should be between 0 and 100 exclusive + it('4 handles use exact prior positions', () => { + expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85]) + }) +}) + +describe('bottomHandlePositions — uniform spacing for ≥5', () => { + it('returns count entries', () => { + expect(bottomHandlePositions(5)).toHaveLength(5) + expect(bottomHandlePositions(12)).toHaveLength(12) + expect(bottomHandlePositions(48)).toHaveLength(48) + }) + + it('all positions strictly between 0 and 100', () => { + const pos = bottomHandlePositions(48) pos.forEach((p) => { expect(p).toBeGreaterThan(0) expect(p).toBeLessThan(100) }) - // Positions should be strictly increasing + }) + + it('positions are strictly increasing and uniform', () => { + const pos = bottomHandlePositions(12) for (let i = 1; i < pos.length; i++) { expect(pos[i]).toBeGreaterThan(pos[i - 1]) } + const step = 100 / 13 + expect(pos[0]).toBeCloseTo(step, 5) + expect(pos[11]).toBeCloseTo(step * 12, 5) + }) + + it('clamps out-of-range counts before computing', () => { + expect(bottomHandlePositions(0)).toEqual([50]) + expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES) }) }) @@ -63,16 +117,10 @@ describe('normalizeHandle', () => { expect(normalizeHandle('bottom-t')).toBe('bottom') }) - it('maps bottom-2-t → bottom-2', () => { + it('maps bottom-N-t → bottom-N for any N', () => { expect(normalizeHandle('bottom-2-t')).toBe('bottom-2') - }) - - it('maps bottom-3-t → bottom-3', () => { - expect(normalizeHandle('bottom-3-t')).toBe('bottom-3') - }) - - it('maps bottom-4-t → bottom-4', () => { - expect(normalizeHandle('bottom-4-t')).toBe('bottom-4') + expect(normalizeHandle('bottom-12-t')).toBe('bottom-12') + expect(normalizeHandle('bottom-48-t')).toBe('bottom-48') }) it('passes through non-stub handles unchanged', () => { @@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => { }) it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => { - const removed = removedBottomHandleIds(4, 1) - expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4'])) + expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4'])) }) it('4 → 2 removes bottom-3, bottom-4', () => { - const removed = removedBottomHandleIds(4, 2) - expect(removed).toEqual(new Set(['bottom-3', 'bottom-4'])) + expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4'])) }) it('3 → 2 removes only bottom-3', () => { - const removed = removedBottomHandleIds(3, 2) - expect(removed).toEqual(new Set(['bottom-3'])) + expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3'])) }) it('never removes "bottom" (index 0)', () => { - const removed = removedBottomHandleIds(4, 1) + expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false) + }) + + // Regression: scaling the cap from 4 → 48 must not break the remap loop. + it('scales to high counts (48 → 1 removes 47 ids)', () => { + const removed = removedBottomHandleIds(48, 1) + expect(removed.size).toBe(47) + expect(removed.has('bottom-2')).toBe(true) + expect(removed.has('bottom-48')).toBe(true) expect(removed.has('bottom')).toBe(false) }) + + it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => { + const removed = removedBottomHandleIds(10, 2) + expect(removed.size).toBe(8) + expect(removed.has('bottom-2')).toBe(false) + expect(removed.has('bottom-3')).toBe(true) + expect(removed.has('bottom-10')).toBe(true) + }) }) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 562c400..ccd85ae 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData, Waypoint } from '@/types' -import { normalizeHandle } from '@/utils/handleUtils' +import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils' // ── Types ──────────────────────────────────────────────────────────────────── @@ -104,7 +104,7 @@ export function serializeNode(n: Node): Record { properties: n.data.properties ?? [], width: n.measured?.width ?? n.width ?? null, height: n.measured?.height ?? n.height ?? null, - bottom_handles: n.data.bottom_handles ?? 1, + bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), pos_x: n.position.x, pos_y: n.position.y, } @@ -155,7 +155,7 @@ export function deserializeApiNode( id: n.id, type: normalizedType, position: { x: n.pos_x, y: n.pos_y }, - data: { ...n, type: normalizedType } as unknown as NodeData, + data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false ? { width: n.width ?? 300, height: n.height ?? 200 } diff --git a/frontend/src/utils/handleUtils.ts b/frontend/src/utils/handleUtils.ts index e82df5c..0921d0f 100644 --- a/frontend/src/utils/handleUtils.ts +++ b/frontend/src/utils/handleUtils.ts @@ -2,20 +2,44 @@ * Bottom handle configuration for multi-handle nodes. * * Handle IDs: index 0 = 'bottom' (always the default, backward-compatible) - * index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4' + * index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48') * * Invisible target handles follow the same pattern with a '-t' suffix: - * 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t' + * 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t' */ -export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const +export const MIN_BOTTOM_HANDLES = 1 +export const MAX_BOTTOM_HANDLES = 48 -/** Left % position for each handle slot, per count. */ -export const BOTTOM_HANDLE_POSITIONS: Record = { - 1: [50], - 2: [25, 75], - 3: [20, 50, 80], - 4: [15, 38, 62, 85], +/** Returns the source handle ID at a given slot index. */ +export function bottomHandleId(idx: number): string { + return idx === 0 ? 'bottom' : `bottom-${idx + 1}` +} + +/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */ +export function clampBottomHandles(n: unknown): number { + if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES + const i = Math.floor(n) + if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES + if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES + return i +} + +/** + * Left % positions for each handle slot. + * Counts 1..4 keep their original hand-tuned values to preserve exact pixel + * positions on canvases saved before the multi-handle expansion. + * Counts ≥ 5 use uniform spacing. + */ +export function bottomHandlePositions(count: number): number[] { + const c = clampBottomHandles(count) + switch (c) { + case 1: return [50] + case 2: return [25, 75] + case 3: return [20, 50, 80] + case 4: return [15, 38, 62, 85] + default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1)) + } } /** @@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null { export function removedBottomHandleIds(oldCount: number, newCount: number): Set { const removed = new Set() for (let i = newCount; i < oldCount; i++) { - removed.add(BOTTOM_HANDLE_IDS[i]) + removed.add(bottomHandleId(i)) } return removed }