feat: group node types by category in Custom Style editor

The Custom Style modal listed node types as a flat list. It now groups
them under category headers (Hardware, Virtualization, IoT, Zigbee,
Z-Wave, Personal, Generic) like the Add/Edit Node modal, and exposes the
Z-Wave node types for styling.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-26 16:02:23 +02:00
parent 5b08d57124
commit ecf3cbdfe4
2 changed files with 56 additions and 35 deletions
@@ -1,9 +1,9 @@
import { useState, useCallback } from 'react' import { Fragment, useState, useCallback } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame, Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
Radio, Zap, Lightbulb, Radio, Zap, Lightbulb, RadioTower, Share2,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -17,13 +17,16 @@ import type {
} from '@/types' } from '@/types'
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
// ── Node types exposed for custom style (skip groupRect/group) ─────────────── // ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
const EDITABLE_NODE_TYPES: NodeType[] = [ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice', { label: 'IoT', types: ['iot', 'camera', 'cpl'] },
'generic', { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
{ label: 'Generic', types: ['generic'] },
] ]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical'] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
@@ -34,6 +37,7 @@ const NODE_ICONS: Record<string, LucideIcon> = {
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap, camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
docker_host: Anchor, docker_container: Package, docker_host: Anchor, docker_container: Package,
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb,
generic: Circle, generic: Circle,
} }
@@ -363,34 +367,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
{/* Type list */} {/* Type list */}
<div className="flex-1 overflow-y-auto py-1"> <div className="flex-1 overflow-y-auto py-1">
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { {tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => (
const Icon = NODE_ICONS[t] ?? Circle <Fragment key={group.label}>
const style = draft.nodes[t] <div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[#8b949e]/60">
const isSelected = selection?.kind === 'node' && selection.type === t {group.label}
const swatchColor = style </div>
? applyOpacity(style.borderColor, style.borderOpacity) {group.types.map((t) => {
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' const Icon = NODE_ICONS[t] ?? Circle
const style = draft.nodes[t]
const isSelected = selection?.kind === 'node' && selection.type === t
const swatchColor = style
? applyOpacity(style.borderColor, style.borderOpacity)
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
return ( return (
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => setSelection({ kind: 'node', type: t })} onClick={() => setSelection({ kind: 'node', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left" className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{ style={{
background: isSelected ? '#21262d' : 'transparent', background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e', color: isSelected ? '#e6edf3' : '#8b949e',
}} }}
> >
<Icon size={13} /> <Icon size={13} />
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span> <span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: swatchColor }} style={{ background: swatchColor }}
/> />
</button> </button>
) )
})} })}
</Fragment>
))}
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
const style = draft.edges[t] const style = draft.edges[t]
@@ -37,6 +37,16 @@ describe('CustomStyleModal', () => {
expect(screen.getByText(/edge type from the list/i)).toBeDefined() expect(screen.getByText(/edge type from the list/i)).toBeDefined()
}) })
it('groups node types under category headers (incl. Zigbee and Z-Wave)', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('Zigbee')).toBeDefined()
expect(screen.getByText('Z-Wave')).toBeDefined()
// A Z-Wave node type is selectable from its category.
fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ }))
expect(screen.getByText(/Apply to existing Z-Wave Controller/)).toBeDefined()
})
it('selecting a node type opens the node editor', () => { it('selecting a node type opens the node editor', () => {
render(<CustomStyleModal open onClose={vi.fn()} />) render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' })) fireEvent.click(screen.getByRole('button', { name: 'Router' }))