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 { useState, useEffect, useRef } from 'react'
|
||||||
import { api } from '../api'
|
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 DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
|
||||||
const HIDDEN_COLS = new Set(['id', '_overridden'])
|
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 [selectedRow, setSelectedRow] = useState(null) // raw view row (has id)
|
||||||
const [selectedRecord, setSelectedRecord] = useState(null) // full record from API
|
const [selectedRecord, setSelectedRecord] = useState(null) // full record from API
|
||||||
const [overrideDraft, setOverrideDraft] = useState({}) // { field: newValue }
|
const [overrideDraft, setOverrideDraft] = useState({}) // { field: newValue }
|
||||||
|
const [extraFields, setExtraFields] = useState([]) // [{field, value}] for new keys
|
||||||
const [panelLoading, setPanelLoading] = useState(false)
|
const [panelLoading, setPanelLoading] = useState(false)
|
||||||
const [panelSaving, setPanelSaving] = useState(false)
|
const [panelSaving, setPanelSaving] = useState(false)
|
||||||
const [panelMsg, setPanelMsg] = useState(null)
|
const [panelMsg, setPanelMsg] = useState(null)
|
||||||
@ -109,6 +158,7 @@ export default function Records({ source }) {
|
|||||||
setSelectedRow(row)
|
setSelectedRow(row)
|
||||||
setSelectedRecord(null)
|
setSelectedRecord(null)
|
||||||
setOverrideDraft({})
|
setOverrideDraft({})
|
||||||
|
setExtraFields([])
|
||||||
setPanelMsg(null)
|
setPanelMsg(null)
|
||||||
|
|
||||||
const id = row.id
|
const id = row.id
|
||||||
@ -134,6 +184,7 @@ export default function Records({ source }) {
|
|||||||
setSelectedRow(null)
|
setSelectedRow(null)
|
||||||
setSelectedRecord(null)
|
setSelectedRecord(null)
|
||||||
setOverrideDraft({})
|
setOverrideDraft({})
|
||||||
|
setExtraFields([])
|
||||||
setPanelMsg(null)
|
setPanelMsg(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +193,9 @@ export default function Records({ source }) {
|
|||||||
setPanelSaving(true)
|
setPanelSaving(true)
|
||||||
setPanelMsg(null)
|
setPanelMsg(null)
|
||||||
try {
|
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)
|
setSelectedRecord(updated)
|
||||||
setOverrideDraft(updated.overrides || {})
|
setOverrideDraft(updated.overrides || {})
|
||||||
setPanelMsg({ text: 'Saved.', ok: true })
|
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 displayCols = (rows.length > 0 ? Object.keys(rows[0]) : cols).filter(c => !HIDDEN_COLS.has(c))
|
||||||
const visCols = 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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 overflow-hidden">
|
<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>}
|
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading…</p>}
|
||||||
|
|
||||||
{selectedRecord && !panelLoading && (
|
{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 && (
|
{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}
|
{panelMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-gray-400">
|
<table className="w-full text-xs">
|
||||||
Click any field to override its value. Overrides survive reprocess.
|
<thead>
|
||||||
</div>
|
<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>
|
||||||
<div className="flex flex-col gap-1">
|
<th className="px-3 py-1.5 font-medium">value</th>
|
||||||
|
<th className="w-6" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{transformedFields.map(field => {
|
{transformedFields.map(field => {
|
||||||
const currentVal = selectedRecord.transformed[field]
|
const currentVal = selectedRecord.transformed[field]
|
||||||
const isOverridden = field in overrideDraft
|
const isOverridden = field in overrideDraft
|
||||||
|
const suggestions = [...(valuesByField[field] || [])].sort()
|
||||||
return (
|
return (
|
||||||
<div key={field} className={`rounded px-2 py-1.5 ${isOverridden ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
|
<tr key={field} className={`border-t border-gray-50 ${isOverridden ? 'bg-amber-50' : ''}`}>
|
||||||
<div className={`text-xs font-mono mb-0.5 ${isOverridden ? 'text-amber-700' : 'text-gray-400'}`}>
|
<td className={`px-3 py-1 font-mono whitespace-nowrap ${isOverridden ? 'text-amber-700' : 'text-gray-400'}`}>
|
||||||
{field}
|
{field}
|
||||||
{isOverridden && <span className="ml-1 text-amber-500">✎</span>}
|
</td>
|
||||||
</div>
|
<td className="px-1 py-1">
|
||||||
<input
|
<AutocompleteInput
|
||||||
className={`w-full text-xs font-mono bg-transparent border-0 focus:outline-none focus:ring-0 ${isOverridden ? 'text-amber-800' : 'text-gray-700'}`}
|
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||||||
value={isOverridden ? overrideDraft[field] : (currentVal ?? '')}
|
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'
|
||||||
onChange={e => setOverrideDraft(d => ({ ...d, [field]: e.target.value }))}
|
}`}
|
||||||
|
value={isOverridden ? overrideDraft[field] : String(currentVal ?? '')}
|
||||||
|
onChange={v => setOverrideDraft(d => ({ ...d, [field]: v }))}
|
||||||
|
onEnter={handleSaveOverrides}
|
||||||
|
suggestions={suggestions}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!(field in overrideDraft)) {
|
if (!(field in overrideDraft))
|
||||||
setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') }))
|
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
|
<button
|
||||||
onClick={handleSaveOverrides}
|
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">
|
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'}
|
{panelSaving ? 'Saving…' : 'Save overrides'}
|
||||||
</button>
|
</button>
|
||||||
@ -378,7 +505,7 @@ export default function Records({ source }) {
|
|||||||
onClick={handleClearOverrides}
|
onClick={handleClearOverrides}
|
||||||
disabled={panelSaving}
|
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">
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user