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
This commit is contained in:
@@ -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
|
||||
</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
|
||||
size="sm"
|
||||
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
|
||||
@@ -6,11 +6,12 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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 { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
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'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
@@ -24,6 +25,65 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ 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 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'
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
{/* Connection points per side (not for group containers) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_BOTTOM_HANDLES}
|
||||
max={MAX_BOTTOM_HANDLES}
|
||||
step={1}
|
||||
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||
aria-label="Bottom connection points slider"
|
||||
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||
<div className="flex flex-col gap-2.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Connection Points</Label>
|
||||
{/* Spatial cross: each side's stepper sits where that side is. */}
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center justify-items-center gap-x-2 gap-y-2 py-1">
|
||||
<div />
|
||||
<CPStepper label="Top" side="top" value={sideValue('top')}
|
||||
onChange={(v) => set('top_handles', v)} />
|
||||
<div />
|
||||
|
||||
<CPStepper label="Left" side="left" value={sideValue('left')}
|
||||
onChange={(v) => set('left_handles', v)} />
|
||||
<div
|
||||
className="flex items-center justify-center rounded-md border text-[9px] uppercase tracking-wide font-medium select-none"
|
||||
style={{
|
||||
width: 64, height: 40,
|
||||
borderColor: resolvedNodeColors.border,
|
||||
background: `${resolvedNodeColors.background}`,
|
||||
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 className="flex items-center justify-between pt-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -135,6 +135,34 @@ describe('CustomStyleModal', () => {
|
||||
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)', () => {
|
||||
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
||||
// happen on the open-prop edge, not via Radix onOpenChange.
|
||||
|
||||
@@ -435,57 +435,88 @@ describe('NodeModal', () => {
|
||||
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 })
|
||||
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' } })
|
||||
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' } })
|
||||
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 })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('1')
|
||||
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).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', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('3')
|
||||
it('pre-fills each side from initial', () => {
|
||||
renderModal({ initial: { ...BASE, top_handles: 2, bottom_handles: 3, left_handles: 4, right_handles: 1 } })
|
||||
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('2')
|
||||
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 slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
fireEvent.change(slider, { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByLabelText('Left connection points'), { target: { value: '3' } })
|
||||
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 slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('64')
|
||||
fireEvent.change(slider, { target: { value: '52' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
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' }))
|
||||
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 } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('64')
|
||||
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('64')
|
||||
})
|
||||
|
||||
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
||||
|
||||
Reference in New Issue
Block a user