fix(rack): drop the cable type filter, and dress the visibility select like the header

Two native <select> elements sat in a header made of ghost buttons, and they
looked it: their own border and background, an OS chevron, an unthemed popup.

The type filter goes entirely — state, action and the CableLayer branch that
read it, plus CABLE_TYPE_LABELS which had no other consumer. Filtering the
cabling down to copper or fibre answered a question nobody asks of a rack
they are looking at; the type still follows the port a patch starts from,
which is where it matters.

The visibility control becomes the shared ui/select, with a trigger sized and
coloured like the buttons beside it — transparent until hovered — and an icon
per option: pointer for on-hover, eye for always, crossed eye for hidden.

Tests cover the trigger reflecting the active visibility, a pick writing it to
the store, and CableLayer drawing ethernet and fibre alike. base-ui commits a
selection on a real pointer sequence rather than a bare click, so that one goes
through userEvent.

ha-relevant: no
This commit is contained in:
Pouzor
2026-08-09 20:35:23 +02:00
committed by Pouzor - Rémy Jardient
parent a70fc248bf
commit b1186bd7dd
7 changed files with 66 additions and 43 deletions
@@ -6,22 +6,29 @@
* much of the cabling is on screen.
*/
import { useState } from 'react'
import { Cable, Link2, Plus, Trash2, X } from 'lucide-react'
import { Cable, Eye, EyeOff, Link2, MousePointer2, Plus, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRackStore } from '@/rack/store'
import { useRackPalette } from '@/rack/rackTheme'
import { loadNetworkLinks } from '@/rack/networkLinks'
import { CABLE_TYPE_LABELS } from '@/rack/rackDefaults'
import { useDesignStore } from '@/stores/designStore'
import type { CableType, CableVisibility } from '@/types'
import type { CableVisibility } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const ghost =
'gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]'
const selectClass =
'rounded border border-border bg-[#0d1117] px-2 py-1 text-xs text-foreground outline-none focus:border-[#00d4ff] cursor-pointer'
/** Reads as one of the ghost buttons around it: no box until you hover it. */
const triggerClass =
'gap-1.5 border-transparent bg-transparent px-2 text-xs font-medium text-muted-foreground hover:bg-[#21262d] hover:text-foreground dark:bg-transparent dark:hover:bg-[#21262d] cursor-pointer'
const VISIBILITY_OPTIONS: { value: CableVisibility; label: string; icon: typeof Eye }[] = [
{ value: 'hover', label: 'Cables on hover', icon: MousePointer2 },
{ value: 'always', label: 'Cables always', icon: Eye },
{ value: 'hidden', label: 'Cables hidden', icon: EyeOff },
]
export function RackToolbarActions() {
const palette = useRackPalette()
@@ -30,8 +37,6 @@ export function RackToolbarActions() {
const toggleCableMode = useRackStore((s) => s.toggleCableMode)
const cableVisibility = useRackStore((s) => s.cableVisibility)
const setCableVisibility = useRackStore((s) => s.setCableVisibility)
const cableTypeFilter = useRackStore((s) => s.cableTypeFilter)
const setCableTypeFilter = useRackStore((s) => s.setCableTypeFilter)
const cableDraft = useRackStore((s) => s.cableDraft)
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
const selectedCableId = useRackStore((s) => s.selectedCableId)
@@ -95,30 +100,23 @@ export function RackToolbarActions() {
</Button>
)}
<select
className={selectClass}
aria-label="Cable visibility"
value={cableVisibility}
onChange={(e) => setCableVisibility(e.target.value as CableVisibility)}
>
<option value="hover">Cables on hover</option>
<option value="always">Cables always</option>
<option value="hidden">Cables hidden</option>
</select>
<select
className={selectClass}
aria-label="Cable type filter"
value={cableTypeFilter}
onChange={(e) => setCableTypeFilter(e.target.value as CableType | 'all')}
>
<option value="all">All types</option>
{(Object.keys(CABLE_TYPE_LABELS) as CableType[]).map((t) => (
<option key={t} value={t}>
{CABLE_TYPE_LABELS[t]}
</option>
))}
</select>
<Select value={cableVisibility} onValueChange={(v) => setCableVisibility(v as CableVisibility)}>
<SelectTrigger size="sm" className={triggerClass} aria-label="Cable visibility">
<SelectValue>
{(() => {
const { icon: Icon, label } = VISIBILITY_OPTIONS.find((o) => o.value === cableVisibility)!
return <><Icon size={14} /> {label}</>
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{VISIBILITY_OPTIONS.map(({ value, icon: Icon, label }) => (
<SelectItem key={value} value={value} className="text-xs">
<Icon size={14} /> {label}
</SelectItem>
))}
</SelectContent>
</Select>
{!STANDALONE && (
<Button
@@ -4,6 +4,7 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RackToolbarActions } from '../RackToolbarActions'
import { useRackStore } from '@/rack/store'
@@ -15,6 +16,32 @@ beforeEach(() => {
const unplug = () => screen.queryByRole('button', { name: /unplug/i })
describe('RackToolbarActions cable visibility', () => {
it('shows the active visibility on the trigger', () => {
useRackStore.getState().setCableVisibility('always')
render(<RackToolbarActions />)
expect(screen.getByLabelText('Cable visibility')).toHaveTextContent('Cables always')
})
it('applies the picked visibility to the store', async () => {
// base-ui commits its selection on real pointer sequences, not a bare click.
const user = userEvent.setup({ pointerEventsCheck: 0 })
render(<RackToolbarActions />)
await user.click(screen.getByLabelText('Cable visibility'))
await user.click(await screen.findByRole('option', { name: 'Cables hidden' }))
expect(useRackStore.getState().cableVisibility).toBe('hidden')
})
it('carries no cable type filter — copper and fibre always draw together', () => {
render(<RackToolbarActions />)
expect(screen.queryByLabelText('Cable type filter')).toBeNull()
expect(screen.queryByText('All types')).toBeNull()
expect('cableTypeFilter' in useRackStore.getState()).toBe(false)
})
})
describe('RackToolbarActions cable selection', () => {
it('hides Unplug outside patch mode', () => {
useRackStore.getState().selectCable(useRackStore.getState().cables[0].id)
+1 -1
View File
@@ -60,7 +60,7 @@ types in `@/types/rack`, narrowing every enum on the way in.
## Panel behaviour in rack mode
- **Header**: undo/redo, auto layout, YAML import/export, MD and live View are hidden. Add Rack, Patch, cable visibility, cable type filter and Import links replace them. PNG export and Save stay.
- **Header**: undo/redo, auto layout, YAML import/export, MD and live View are hidden. Add Rack, Patch, cable visibility and Import links replace them. PNG export and Save stay.
- **Left rail**: the design switcher, Device Inventory, Scan History, Settings and Logout stay. The node/zone/text/scan/import block becomes the accessory tray plus **+ Device**. Devices themselves are not listed there — they live in the Device Inventory modal. The footer counts racks, mounts, cables and free U instead of online/offline nodes.
- **Right rail**: none. A mount is edited in `RackDeviceModal`, a rack in `RackSettingsModal`, so the canvas keeps the full width.
@@ -81,6 +81,15 @@ describe('CableLayer selection', () => {
expect(band.querySelector('path')!.getAttribute('stroke-dasharray')).toBe('5 4')
})
it('draws copper and fibre alike — there is no type filter any more', () => {
useRackStore.getState().toggleCableMode()
const { cables } = useRackStore.getState()
expect(new Set(cables.map((c) => c.type))).toEqual(new Set(['ethernet', 'fiber']))
const { container } = render(<CableLayer />)
expect(hitPaths(container)).toHaveLength(cables.length)
})
it('draws the selected cable with a highlight halo', () => {
useRackStore.getState().toggleCableMode()
const { container, rerender } = render(<CableLayer />)
@@ -29,7 +29,6 @@ export function CableLayer() {
const devices = useRackStore((s) => s.devices)
const cables = useRackStore((s) => s.cables)
const visibility = useRackStore((s) => s.cableVisibility)
const typeFilter = useRackStore((s) => s.cableTypeFilter)
const hoveredDeviceId = useRackStore((s) => s.hoveredDeviceId)
const selectedDeviceId = useRackStore((s) => s.selectedDeviceId)
const cableMode = useRackStore((s) => s.cableMode)
@@ -57,7 +56,6 @@ export function CableLayer() {
const visible: Resolved[] = []
for (const cable of cables) {
if (typeFilter !== 'all' && cable.type !== typeFilter) continue
if (!showAll) {
if (!focus) continue
if (cable.from.deviceId !== focus && cable.to.deviceId !== focus) continue
-5
View File
@@ -14,11 +14,6 @@ export const CABLE_COLORS: Record<CableType, string> = {
fiber: '#f0a500',
}
export const CABLE_TYPE_LABELS: Record<CableType, string> = {
ethernet: 'Ethernet',
fiber: 'Fiber',
}
/** Cable type implied by the port a patch starts from. */
export const PORT_CABLE_TYPE: Record<PortType, CableType> = {
rj45: 'ethernet',
-4
View File
@@ -112,7 +112,6 @@ interface RackState {
* `moved` separates a drag from a plain click on the same port.
*/
cableDrag: { pointer: { x: number; y: number } | null; moved: boolean } | null
cableTypeFilter: CableType | 'all'
/**
* Open editor dialogs. Device and rack settings live in modals, so any
* component (sidebar, canvas, header) can open them without prop drilling.
@@ -189,7 +188,6 @@ interface RackState {
removeSelectedCable: () => void
hoverDevice: (id: string | null) => void
setCableVisibility: (v: CableVisibility) => void
setCableTypeFilter: (t: CableType | 'all') => void
toggleCableMode: () => void
pickPort: (deviceId: string, portId: string) => void
/**
@@ -235,7 +233,6 @@ function emptyState() {
cableMode: false,
cableDraft: null,
cableDrag: null,
cableTypeFilter: 'all' as const,
networkImportDone: false,
deviceEditor: null,
rackEditorId: null,
@@ -695,7 +692,6 @@ export const useRackStore = create<RackState>((set, get) => {
},
hoverDevice: (id) => set({ hoveredDeviceId: id }),
setCableVisibility: (v) => set({ cableVisibility: v, cableVisibilityBeforePatch: null }),
setCableTypeFilter: (t) => set({ cableTypeFilter: t }),
toggleCableMode: () =>
set((s) => {