Redesign Records override panel: table layout, add-new-field support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-25 09:55:23 -04:00
parent 7a7fd01285
commit 5951cbbba3

View File

@ -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 (
<div className="relative">
<input ref={inputRef} className={className} value={value} placeholder={placeholder}
onChange={e => { 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 && (
<div ref={listRef} className="absolute z-50 left-0 top-full mt-0.5 bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto min-w-full">
{filtered.map((s, i) => (
<div key={s} className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`}
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
))}
</div>
)}
</div>
)
}
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 (
<div className="flex h-full min-h-0 overflow-hidden">
@ -330,46 +399,104 @@ export default function Records({ source }) {
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading</p>}
{selectedRecord && !panelLoading && (
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-3">
<div className="flex-1 overflow-y-auto flex flex-col min-h-0">
{panelMsg && (
<div className={`text-xs ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
<div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
{panelMsg.text}
</div>
)}
<div className="text-xs text-gray-400">
Click any field to override its value. Overrides survive reprocess.
</div>
<div className="flex flex-col gap-1">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
<th className="px-3 py-1.5 font-medium w-32">field</th>
<th className="px-3 py-1.5 font-medium">value</th>
<th className="w-6" />
</tr>
</thead>
<tbody>
{transformedFields.map(field => {
const currentVal = selectedRecord.transformed[field]
const isOverridden = field in overrideDraft
const suggestions = [...(valuesByField[field] || [])].sort()
return (
<div key={field} className={`rounded px-2 py-1.5 ${isOverridden ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
<div className={`text-xs font-mono mb-0.5 ${isOverridden ? 'text-amber-700' : 'text-gray-400'}`}>
<tr key={field} className={`border-t border-gray-50 ${isOverridden ? 'bg-amber-50' : ''}`}>
<td className={`px-3 py-1 font-mono whitespace-nowrap ${isOverridden ? 'text-amber-700' : 'text-gray-400'}`}>
{field}
{isOverridden && <span className="ml-1 text-amber-500"></span>}
</div>
<input
className={`w-full text-xs font-mono bg-transparent border-0 focus:outline-none focus:ring-0 ${isOverridden ? 'text-amber-800' : 'text-gray-700'}`}
value={isOverridden ? overrideDraft[field] : (currentVal ?? '')}
onChange={e => setOverrideDraft(d => ({ ...d, [field]: e.target.value }))}
</td>
<td className="px-1 py-1">
<AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
isOverridden ? 'bg-amber-50 border-amber-300 text-amber-800' : 'bg-transparent border-transparent hover:border-gray-200 focus:border-blue-300 text-gray-700'
}`}
value={isOverridden ? overrideDraft[field] : String(currentVal ?? '')}
onChange={v => setOverrideDraft(d => ({ ...d, [field]: v }))}
onEnter={handleSaveOverrides}
suggestions={suggestions}
onFocus={() => {
if (!(field in overrideDraft)) {
if (!(field in overrideDraft))
setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') }))
}
}}
/>
</div>
</td>
<td className="pr-2 text-center">
{isOverridden && (
<button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[field]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base"
title="Clear override">×</button>
)}
</td>
</tr>
)
})}
</div>
<div className="flex gap-2 pt-1">
{/* New field rows */}
{extraFields.map((ef, i) => (
<tr key={`extra-${i}`} className="border-t border-gray-50 bg-blue-50">
<td className="px-1 py-1">
<AutocompleteInput
className="w-full text-xs font-mono px-2 py-0.5 rounded border border-blue-200 focus:outline-none focus:border-blue-400 bg-white text-gray-700"
value={ef.field}
placeholder="field name…"
onChange={v => setExtraFields(fs => fs.map((f, j) => j === i ? { ...f, field: v } : f))}
suggestions={fieldSuggestions}
/>
</td>
<td className="px-1 py-1">
<AutocompleteInput
className="w-full text-xs font-mono px-2 py-0.5 rounded border border-blue-200 focus:outline-none focus:border-blue-400 bg-white text-gray-700"
value={ef.value}
placeholder="value…"
onChange={v => setExtraFields(fs => fs.map((f, j) => j === i ? { ...f, value: v } : f))}
suggestions={ef.field ? [...(valuesByField[ef.field] || [])].sort() : []}
onEnter={handleSaveOverrides}
/>
</td>
<td className="pr-2 text-center">
<button
onClick={() => setExtraFields(fs => fs.filter((_, j) => j !== i))}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
</td>
</tr>
))}
<tr className="border-t border-gray-100">
<td colSpan={3} className="px-3 py-1.5">
<button
onClick={() => setExtraFields(fs => [...fs, { field: '', value: '' }])}
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5">
+ add field
</button>
</td>
</tr>
</tbody>
</table>
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 mt-auto">
<button
onClick={handleSaveOverrides}
disabled={panelSaving || Object.keys(overrideDraft).length === 0}
disabled={panelSaving || !isDirty}
className="flex-1 text-xs bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-40">
{panelSaving ? 'Saving…' : 'Save overrides'}
</button>
@ -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
</button>
)}
</div>