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
This commit is contained in:
+14
-1
@@ -28,6 +28,7 @@ import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
|||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
|
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||||
@@ -41,7 +42,7 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
|||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
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 { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export default function App() {
|
|||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
|
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
@@ -755,6 +757,7 @@ export default function App() {
|
|||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
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 }))}
|
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 */}
|
{/* 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 }))
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||||
})()}
|
})()}
|
||||||
currentNodeId={editNodeId ?? undefined}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
|
onEditTypeStyle={setStyleEditorType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -916,6 +920,15 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Standalone Custom Style editor, opened from a node's Appearance
|
||||||
|
shortcut with that node's type preselected. */}
|
||||||
|
<CustomStyleModal
|
||||||
|
key={styleEditorType ? `style-${styleEditorType}` : 'style-closed'}
|
||||||
|
open={styleEditorType !== null}
|
||||||
|
initialNodeType={styleEditorType ?? undefined}
|
||||||
|
onClose={() => setStyleEditorType(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
|
|||||||
@@ -300,9 +300,11 @@ type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeTy
|
|||||||
interface CustomStyleModalProps {
|
interface CustomStyleModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
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 { customStyle, setCustomStyle } = useThemeStore()
|
||||||
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
||||||
|
|
||||||
@@ -320,8 +322,13 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
|
if (initialNodeType) {
|
||||||
|
setTab('nodes')
|
||||||
|
setSelection({ kind: 'node', type: initialNodeType })
|
||||||
|
} else {
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
||||||
// clobber an in-progress edit, only a fresh open should reset.
|
// clobber an in-progress edit, only a fresh open should reset.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, createElement, useState } from 'react'
|
import { Fragment, createElement, useState } from 'react'
|
||||||
import modalStyles from './modal-interactive.module.css'
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -132,11 +132,13 @@ interface NodeModalProps {
|
|||||||
title?: string
|
title?: string
|
||||||
parentCandidates?: ParentCandidate[]
|
parentCandidates?: ParentCandidate[]
|
||||||
currentNodeId?: string
|
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
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// 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 }
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
@@ -611,6 +613,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onEditTypeStyle && form.type !== 'group' && form.type !== 'groupRect' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditTypeStyle((form.type ?? 'generic') as NodeType)}
|
||||||
|
className="flex items-center gap-1 self-start text-[10px] text-[#00d4ff] hover:underline"
|
||||||
|
>
|
||||||
|
<Palette size={10} /> Edit {NODE_TYPE_LABELS[form.type ?? 'generic']} style for all nodes on the canvas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection points per side (not for group containers) */}
|
{/* Connection points per side (not for group containers) */}
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(screen.getByText('Default size')).toBeDefined()
|
expect(screen.getByText('Default size')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('initialNodeType preselects that type editor on open (NodeModal shortcut)', () => {
|
||||||
|
render(<CustomStyleModal open initialNodeType="switch" onClose={vi.fn()} />)
|
||||||
|
// 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', () => {
|
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
|||||||
@@ -105,6 +105,21 @@ describe('NodeModal', () => {
|
|||||||
confirmSpy.mockRestore()
|
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 ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user