fix: persist group description with Ctrl+S, not just on blur

The description field committed to the store only on textarea blur, so
saving via Ctrl+S (the primary shortcut, which never blurs the field)
dropped the edit. Make it controlled and commit on each change; snapshot
history once per edit session (on focus) so undo stays a single step.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-11 01:48:22 +02:00
parent 7ea1fc2d69
commit fe3787f0cd
2 changed files with 31 additions and 26 deletions
+17 -13
View File
@@ -1,4 +1,4 @@
import { createElement, useState } from 'react'
import { createElement, useRef, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -66,10 +66,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
nodes={nodes}
onUngroup={() => { ungroup(node.id) }}
onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }}
onChangeDescription={(value) => {
snapshotHistory()
updateNode(node.id, { notes: value })
}}
onChangeDescription={(value) => updateNode(node.id, { notes: value })}
onSnapshotBeforeEdit={snapshotHistory}
onToggleBorder={() => {
snapshotHistory()
updateNode(node.id, {
@@ -433,22 +431,28 @@ interface GroupDetailPanelProps {
onUngroup: () => void
onRemoveChild: (id: string) => void
onChangeDescription: (value: string) => void
onSnapshotBeforeEdit: () => void
onToggleBorder: () => void
onClose: () => void
onSelectChild: (id: string) => void
}
function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onSnapshotBeforeEdit, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false
// Description reuses data.notes, which already round-trips to the backend.
// Uncontrolled textarea (keyed by node id) so we only snapshot history on blur,
// not on every keystroke. `key` resets the field when switching groups.
const commitDescription = (value: string) => {
if (value === (node.data.notes ?? '')) return
// Controlled + committed on every keystroke so ANY save path (incl. Ctrl+S,
// which never blurs the field) captures it. History is snapshotted once at the
// start of an edit session so the whole edit is a single undo step.
const snappedRef = useRef(false)
const handleDescriptionChange = (value: string) => {
if (!snappedRef.current) {
onSnapshotBeforeEdit()
snappedRef.current = true
}
onChangeDescription(value)
}
@@ -484,9 +488,9 @@ function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescr
</label>
<textarea
id="group-description"
key={node.id}
defaultValue={node.data.notes ?? ''}
onBlur={(e) => commitDescription(e.target.value)}
value={node.data.notes ?? ''}
onFocus={() => { snappedRef.current = false }}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add a description for this group…"
rows={3}
className="mt-1.5 w-full resize-y rounded-md bg-[#21262d] border border-[#30363d] px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50"
@@ -252,24 +252,25 @@ describe('GroupDetailPanel', () => {
expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe('Critical DMZ hosts')
})
it('commits the description on blur', () => {
it('commits the description to the store on each change (so Ctrl+S captures it)', () => {
const updateNode = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'New notes' } })
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
})
it('snapshots history once at the start of an edit, not on every keystroke', () => {
const updateNode = vi.fn()
const snapshotHistory = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode, snapshotHistory })
renderPanel()
const textarea = screen.getByLabelText('Description')
fireEvent.change(textarea, { target: { value: 'New notes' } })
fireEvent.blur(textarea)
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
})
it('does not commit the description on blur when unchanged', () => {
const updateNode = vi.fn()
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.blur(screen.getByLabelText('Description'))
expect(updateNode).not.toHaveBeenCalled()
fireEvent.change(textarea, { target: { value: 'a' } })
fireEvent.change(textarea, { target: { value: 'ab' } })
fireEvent.change(textarea, { target: { value: 'abc' } })
expect(snapshotHistory).toHaveBeenCalledTimes(1)
})
})