Merge branch 'Pouzor:main' into fix/zone-styling

This commit is contained in:
Brett Ferrante
2026-05-03 19:34:44 -04:00
committed by GitHub
21 changed files with 295 additions and 94 deletions
@@ -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 })
@@ -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<Node<NodeData>> {
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 (
<span key={sourceId}>
<Handle
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -10,6 +10,7 @@ type N = NodeProps<Node<NodeData>>
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
@@ -1,4 +1,4 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
@@ -6,6 +6,7 @@ import { GroupNode } from './GroupNode'
export const nodeTypes = {
isp: IspNode,
router: RouterNode,
firewall: FirewallNode,
switch: SwitchNode,
server: ServerNode,
proxmox: ProxmoxGroupNode,
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle,
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
type LucideIcon,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -19,7 +19,7 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
const EDITABLE_NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
'docker_container', 'generic',
]
@@ -27,7 +27,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, switch: Network, server: Server, proxmox: Layers,
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
docker_host: Anchor, docker_container: Package, generic: Circle,
+26 -17
View File
@@ -9,9 +9,10 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
@@ -366,21 +367,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<Select
value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Bottom connection points selector">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
</SelectContent>
</Select>
<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>
</div>
)}
@@ -404,7 +408,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
variant="ghost"
size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
onClick={() => { if (window.confirm('Delete this node?')) onSubmit({ ...form, _delete: true }); onClose(); }}
onClick={() => {
if (window.confirm('Delete this node?')) {
onSubmit({ ...form, _delete: true })
onClose()
}
}}
style={{ minWidth: 64 }}
>
Delete
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
expect(onClose).toHaveBeenCalledOnce()
})
// ── Delete confirm ────────────────────────────────────────────────────
it('deletes and closes when Delete confirm is accepted', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
expect(onClose).toHaveBeenCalledOnce()
confirmSpy.mockRestore()
})
// Regression: bare-if without braces used to call onClose() unconditionally,
// closing the modal even when the user cancelled the confirm dialog.
it('does not delete or close when Delete confirm is cancelled', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onSubmit).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
// ── Label validation ──────────────────────────────────────────────────
it('blocks submit and shows error when label is empty', () => {
@@ -345,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<NodeData>).bottom_handles).toBe(4)
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).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<NodeData>).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')
})
})
+11 -5
View File
@@ -43,13 +43,19 @@ interface SidebarProps {
}
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout)
// When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed
const activeView = forceView ?? _activeView
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) {
setPrevForceView(forceView)
if (forceView) {
setActiveView(forceView)
setCollapsed(false)
}
}
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
@@ -267,6 +267,18 @@ describe('Sidebar', () => {
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
})
// Regression: forceView used to override local state on every render, freezing
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
it('allows switching views after forceView is set by parent', async () => {
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
// Parent keeps forceView as 'history'; user clicks another nav item.
rerender(<Sidebar {...defaultProps} forceView="history" />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
})
it('toggles Settings panel on Settings click', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Settings'))