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:
parent
7a7fd01285
commit
5951cbbba3
@ -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>
|
||||
<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 (
|
||||
<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}
|
||||
</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))
|
||||
setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') }))
|
||||
}}
|
||||
/>
|
||||
</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 className="flex flex-col gap-1">
|
||||
{transformedFields.map(field => {
|
||||
const currentVal = selectedRecord.transformed[field]
|
||||
const isOverridden = field in overrideDraft
|
||||
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'}`}>
|
||||
{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 }))}
|
||||
onFocus={() => {
|
||||
if (!(field in overrideDraft)) {
|
||||
setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user