From e8530ad3db0eaab1b59eaf03381f75a5363ae46a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 4 Jul 2026 15:14:33 +0200 Subject: [PATCH] feat: shortcut from node Appearance to canvas-wide type style Adds a link under the colour swatches in the add/edit node modal that opens the Custom Style editor with the node's type preselected, so a user can edit the style for every node of that type without navigating Style -> Customize -> pick type manually. - CustomStyleModal: new optional `initialNodeType` prop; when set, the modal opens straight into that node type's editor instead of the empty placeholder. - NodeModal: new optional `onEditTypeStyle(type)` prop renders the shortcut link (hidden for group/groupRect and when no handler is wired). - App: `styleEditorType` state; both Add and Edit NodeModals forward the handler, and a standalone CustomStyleModal instance opens preselected (stacked over the node modal so in-progress node edits are preserved). ha-relevant: yes --- frontend/src/App.tsx | 15 ++++++++++++++- .../src/components/modals/CustomStyleModal.tsx | 11 +++++++++-- frontend/src/components/modals/NodeModal.tsx | 15 +++++++++++++-- .../modals/__tests__/CustomStyleModal.test.tsx | 7 +++++++ .../modals/__tests__/NodeModal.test.tsx | 15 +++++++++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b875f5f..925e99f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' +import { CustomStyleModal } from '@/components/modals/CustomStyleModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal' @@ -41,7 +42,7 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' -import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' +import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types' @@ -57,6 +58,7 @@ export default function App() { useStatusPolling() const [themeModalOpen, setThemeModalOpen] = useState(false) + const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) const [scanHistoryOpen, setScanHistoryOpen] = useState(false) const [pendingModalOpen, setPendingModalOpen] = useState(false) @@ -755,6 +757,7 @@ export default function App() { onSubmit={handleAddNode} title="Add Node" parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))} + onEditTypeStyle={setStyleEditorType} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -784,6 +787,7 @@ export default function App() { .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode })) })()} currentNodeId={editNodeId ?? undefined} + onEditTypeStyle={setStyleEditorType} /> setThemeModalOpen(false)} /> + {/* Standalone Custom Style editor, opened from a node's Appearance + shortcut with that node's type preselected. */} + setStyleEditorType(null)} + /> + setSearchOpen(false)} diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index 1d6e5ea..2a44913 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -300,9 +300,11 @@ type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeTy interface CustomStyleModalProps { open: boolean onClose: () => void + /** When opening, preselect this node type's editor (shortcut from NodeModal). */ + initialNodeType?: NodeType } -export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { +export function CustomStyleModal({ open, onClose, initialNodeType }: CustomStyleModalProps) { const { customStyle, setCustomStyle } = useThemeStore() const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore() @@ -320,7 +322,12 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { useEffect(() => { if (open) { setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) - setSelection(null) + if (initialNodeType) { + setTab('nodes') + setSelection({ kind: 'node', type: initialNodeType }) + } else { + setSelection(null) + } } // Intentional snapshot-on-open: we don't want live customStyle changes to // clobber an in-progress edit, only a fresh open should reset. diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index d3412cd..4d622bf 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -1,6 +1,6 @@ import { Fragment, createElement, useState } from 'react' import modalStyles from './modal-interactive.module.css' -import { RotateCcw, ChevronDown } from 'lucide-react' +import { RotateCcw, ChevronDown, Palette } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -132,11 +132,13 @@ interface NodeModalProps { title?: string parentCandidates?: ParentCandidate[] currentNodeId?: string + /** Shortcut: open the Custom Style editor for this node's type (canvas-wide). */ + onEditTypeStyle?: (type: NodeType) => void } // NodeModal is always mounted with a key that changes on open/edit, so useState // initial value is enough - no need for a reset effect. -export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) { +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId, onEditTypeStyle }: NodeModalProps) { const merged = { ...DEFAULT_DATA, ...initial } if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none' const [form, setForm] = useState>(merged) @@ -611,6 +613,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

)} + {onEditTypeStyle && form.type !== 'group' && form.type !== 'groupRect' && ( + + )} {/* Connection points per side (not for group containers) */} diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index a973047..a64cbff 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -54,6 +54,13 @@ describe('CustomStyleModal', () => { expect(screen.getByText('Default size')).toBeDefined() }) + it('initialNodeType preselects that type editor on open (NodeModal shortcut)', () => { + render() + // Editor for Switch is shown immediately, no manual selection needed. + expect(screen.getByText(/Apply to existing Switch/)).toBeDefined() + expect(screen.queryByText(/Select a node type/)).toBeNull() + }) + it('selecting an edge type opens the edge editor with path style buttons', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Edges' })) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 5dd0cb9..5051560 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -105,6 +105,21 @@ describe('NodeModal', () => { confirmSpy.mockRestore() }) + // ── Custom-style shortcut ───────────────────────────────────────────── + + it('shows the type-style shortcut and calls onEditTypeStyle with the node type', () => { + const onEditTypeStyle = vi.fn() + renderModal({ initial: BASE, onEditTypeStyle }) + const link = screen.getByRole('button', { name: /style for all nodes on the canvas/i }) + fireEvent.click(link) + expect(onEditTypeStyle).toHaveBeenCalledWith('server') + }) + + it('omits the shortcut when onEditTypeStyle is not provided', () => { + renderModal({ initial: BASE }) + expect(screen.queryByText(/style for all nodes on the canvas/i)).toBeNull() + }) + // ── Label validation ────────────────────────────────────────────────── it('blocks submit and shows error when label is empty', () => {