From 5951cbbba31641a47c1c53a754c5fc3d7dd44a4b Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 25 Apr 2026 09:55:23 -0400 Subject: [PATCH] Redesign Records override panel: table layout, add-new-field support Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/Records.jsx | 201 ++++++++++++++++++++++++++++++++------- 1 file changed, 164 insertions(+), 37 deletions(-) diff --git a/ui/src/pages/Records.jsx b/ui/src/pages/Records.jsx index 0125018..1466651 100644 --- a/ui/src/pages/Records.jsx +++ b/ui/src/pages/Records.jsx @@ -1,6 +1,54 @@ import { useState, useEffect, useRef } from 'react' import { api } from '../api' +function AutocompleteInput({ value, onChange, onEnter, onFocus, suggestions = [], className, placeholder }) { + const [open, setOpen] = useState(false) + const [highlighted, setHighlighted] = useState(0) + const inputRef = useRef() + const listRef = useRef() + const filtered = value + ? suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase())) + : suggestions + + function select(val) { onChange(val); setOpen(false); inputRef.current?.focus() } + + function handleKeyDown(e) { + if (e.altKey && e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setHighlighted(0); return } + if (open && filtered.length > 0) { + if (e.key === 'Tab') { e.preventDefault(); setHighlighted(h => (h + 1) % filtered.length); return } + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlighted(h => Math.min(h + 1, filtered.length - 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setHighlighted(h => Math.max(h - 1, 0)); return } + if (e.key === 'Enter') { e.preventDefault(); select(filtered[highlighted]); return } + if (e.key === 'Escape') { setOpen(false); return } + } + if (e.key === 'Enter') onEnter?.() + } + + useEffect(() => { + if (!open || !listRef.current) return + listRef.current.children[highlighted]?.scrollIntoView({ block: 'nearest' }) + }, [highlighted, open]) + + return ( +
+ { onChange(e.target.value); if (e.target.value) setOpen(true) }} + onKeyDown={handleKeyDown} + onFocus={onFocus} + onBlur={e => { if (!listRef.current?.contains(e.relatedTarget)) setOpen(false) }} + /> + {open && filtered.length > 0 && ( +
+ {filtered.map((s, i) => ( +
{ e.preventDefault(); select(s) }}>{s}
+ ))} +
+ )} +
+ ) +} + const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/ const HIDDEN_COLS = new Set(['id', '_overridden']) @@ -36,6 +84,7 @@ export default function Records({ source }) { const [selectedRow, setSelectedRow] = useState(null) // raw view row (has id) const [selectedRecord, setSelectedRecord] = useState(null) // full record from API const [overrideDraft, setOverrideDraft] = useState({}) // { field: newValue } + const [extraFields, setExtraFields] = useState([]) // [{field, value}] for new keys const [panelLoading, setPanelLoading] = useState(false) const [panelSaving, setPanelSaving] = useState(false) const [panelMsg, setPanelMsg] = useState(null) @@ -109,6 +158,7 @@ export default function Records({ source }) { setSelectedRow(row) setSelectedRecord(null) setOverrideDraft({}) + setExtraFields([]) setPanelMsg(null) const id = row.id @@ -134,6 +184,7 @@ export default function Records({ source }) { setSelectedRow(null) setSelectedRecord(null) setOverrideDraft({}) + setExtraFields([]) setPanelMsg(null) } @@ -142,7 +193,9 @@ export default function Records({ source }) { setPanelSaving(true) setPanelMsg(null) try { - const updated = await api.setRecordOverrides(selectedRecord.id, overrideDraft) + const merged = { ...overrideDraft } + extraFields.forEach(({ field, value }) => { if (field.trim()) merged[field.trim()] = value }) + const updated = await api.setRecordOverrides(selectedRecord.id, merged) setSelectedRecord(updated) setOverrideDraft(updated.overrides || {}) setPanelMsg({ text: 'Saved.', ok: true }) @@ -180,10 +233,26 @@ export default function Records({ source }) { const displayCols = (rows.length > 0 ? Object.keys(rows[0]) : cols).filter(c => !HIDDEN_COLS.has(c)) const visCols = cols.filter(c => !HIDDEN_COLS.has(c)) - // Fields available for override: keys from transformed - const transformedFields = selectedRecord?.transformed - ? Object.keys(selectedRecord.transformed) - : [] + const transformedFields = selectedRecord?.transformed ? Object.keys(selectedRecord.transformed) : [] + + // Suggestions for new field names: all display cols + any already-overridden keys + const fieldSuggestions = [...new Set([ + ...displayCols, + ...(selectedRecord?.overrides ? Object.keys(selectedRecord.overrides) : []), + ])].filter(f => !transformedFields.includes(f)).sort() + + // Value suggestions per field derived from current page of rows + const valuesByField = {} + rows.forEach(row => { + Object.entries(row).forEach(([k, v]) => { + if (v != null && !HIDDEN_COLS.has(k)) { + if (!valuesByField[k]) valuesByField[k] = new Set() + valuesByField[k].add(String(v)) + } + }) + }) + + const isDirty = Object.keys(overrideDraft).length > 0 || extraFields.some(ef => ef.field.trim()) return (
@@ -330,46 +399,104 @@ export default function Records({ source }) { {panelLoading &&

Loading…

} {selectedRecord && !panelLoading && ( -
+
{panelMsg && ( -
+
{panelMsg.text}
)} -
- Click any field to override its value. Overrides survive reprocess. -
+ + + + + + + + + {transformedFields.map(field => { + const currentVal = selectedRecord.transformed[field] + const isOverridden = field in overrideDraft + const suggestions = [...(valuesByField[field] || [])].sort() + return ( + + + + + + ) + })} -
- {transformedFields.map(field => { - const currentVal = selectedRecord.transformed[field] - const isOverridden = field in overrideDraft - return ( -
-
- {field} - {isOverridden && } -
- setOverrideDraft(d => ({ ...d, [field]: e.target.value }))} - onFocus={() => { - if (!(field in overrideDraft)) { - setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') })) - } - }} - /> -
- ) - })} -
+ {/* New field rows */} + {extraFields.map((ef, i) => ( + + + + + + ))} -
+
+ + + +
fieldvalue +
+ {field} + + setOverrideDraft(d => ({ ...d, [field]: v }))} + onEnter={handleSaveOverrides} + suggestions={suggestions} + onFocus={() => { + if (!(field in overrideDraft)) + setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') })) + }} + /> + + {isOverridden && ( + + )} +
+ setExtraFields(fs => fs.map((f, j) => j === i ? { ...f, field: v } : f))} + suggestions={fieldSuggestions} + /> + + setExtraFields(fs => fs.map((f, j) => j === i ? { ...f, value: v } : f))} + suggestions={ef.field ? [...(valuesByField[ef.field] || [])].sort() : []} + onEnter={handleSaveOverrides} + /> + + +
+ +
+ +
@@ -378,7 +505,7 @@ export default function Records({ source }) { onClick={handleClearOverrides} disabled={panelSaving} className="text-xs border border-gray-200 rounded px-3 py-1.5 text-gray-500 hover:border-red-300 hover:text-red-500 disabled:opacity-40"> - Clear + Clear all )}