get_source_fields only unioned schema fields, raw data keys, rule output_field and mapping output keys. Keys that live solely in records.transformed or records.overrides — a manual override such as dcard's "Note", or a transformed key whose rule was since deleted — never appeared on the source page, so there was no way to add them to the view. Read both columns off the records directly. Records showed no id column, leaving no handle to identify a row. Split the hidden-column set: HIDDEN_COLS still keeps id out of the override editor, GRID_HIDDEN_COLS hides only _overridden, and gridCols() pins id first in the grid and the filter dropdown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDFU3ueEbCYGE37oDgeu2j
658 lines
29 KiB
JavaScript
658 lines
29 KiB
JavaScript
import { useState, useEffect, useRef } from 'react'
|
||
import { api } from '../api'
|
||
|
||
function AutocompleteInput({ value, onChange, onEnter, suggestions = [], className, placeholder }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [highlighted, setHighlighted] = useState(0)
|
||
const [dropPos, setDropPos] = useState(null)
|
||
const inputRef = useRef()
|
||
const listRef = useRef()
|
||
const filtered = value
|
||
? suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase()))
|
||
: suggestions
|
||
|
||
function openList() {
|
||
if (inputRef.current) {
|
||
const r = inputRef.current.getBoundingClientRect()
|
||
setDropPos({ top: r.bottom + 2, left: r.left, minWidth: r.width })
|
||
}
|
||
setOpen(true)
|
||
setHighlighted(0)
|
||
}
|
||
|
||
function select(val) { onChange(val); setOpen(false); inputRef.current?.focus() }
|
||
|
||
function handleKeyDown(e) {
|
||
if (e.altKey && e.key === 'ArrowDown') { e.preventDefault(); openList(); 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) openList() }}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={e => { if (!listRef.current?.contains(e.relatedTarget)) setOpen(false) }}
|
||
/>
|
||
{open && filtered.length > 0 && dropPos && (
|
||
<div ref={listRef}
|
||
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
|
||
className="bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto">
|
||
{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+-]+)?$/
|
||
// Hidden everywhere a field can be edited as an override — you can't override an id
|
||
const HIDDEN_COLS = new Set(['id', '_overridden'])
|
||
// The grid and its filters keep id: it's the only handle on a specific row
|
||
const GRID_HIDDEN_COLS = new Set(['_overridden'])
|
||
|
||
// id first, then the rest in view order
|
||
function gridCols(keys) {
|
||
const visible = keys.filter(c => !GRID_HIDDEN_COLS.has(c))
|
||
return visible.includes('id') ? ['id', ...visible.filter(c => c !== 'id')] : visible
|
||
}
|
||
|
||
function formatVal(val) {
|
||
if (val === null || val === undefined) return null
|
||
const s = String(val)
|
||
if (DATE_RE.test(s)) {
|
||
const d = new Date(s)
|
||
if (!isNaN(d)) {
|
||
const y = d.getUTCFullYear()
|
||
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||
return `${y}-${m}-${day}`
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
export default function Records({ source }) {
|
||
const [rows, setRows] = useState([])
|
||
const [cols, setCols] = useState([])
|
||
const [exists, setExists] = useState(null)
|
||
const [offset, setOffset] = useState(0)
|
||
const [loading, setLoading] = useState(false)
|
||
const [viewError, setViewError] = useState(null)
|
||
const [sort, setSort] = useState({ col: null, dir: 'asc' })
|
||
const [filters, setFilters] = useState([]) // DB sort/filter queries
|
||
const [rowFilter, setRowFilter] = useState('') // regex filter for selecting rows
|
||
const [selected, setSelected] = useState(new Set()) // row IDs selected for bulk override
|
||
const [bulkDraft, setBulkDraft] = useState({}) // bulk override values
|
||
const LIMIT = 100
|
||
|
||
// Override cols — loaded from DB once per source, extended by user via +
|
||
const [overrideCols, setOverrideCols] = useState([]) // keys seen in overrides across all records
|
||
const [extraCols, setExtraCols] = useState([]) // new cols added this session via +
|
||
const [globalValues, setGlobalValues] = useState({}) // picklist suggestions
|
||
|
||
// Override panel
|
||
const [panelOpen, setPanelOpen] = useState(false)
|
||
const [selectedRow, setSelectedRow] = useState(null)
|
||
const [selectedRecord, setSelectedRecord] = useState(null)
|
||
const [overrideDraft, setOverrideDraft] = useState({})
|
||
const [panelLoading, setPanelLoading] = useState(false)
|
||
const [panelSaving, setPanelSaving] = useState(false)
|
||
const [panelMsg, setPanelMsg] = useState(null)
|
||
const debounceRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
if (!source) return
|
||
setOffset(0)
|
||
setSort({ col: null, dir: 'asc' })
|
||
setFilters([])
|
||
setViewError(null)
|
||
setSelectedRecord(null)
|
||
setSelectedRow(null)
|
||
setPanelOpen(false)
|
||
setOverrideCols([])
|
||
setExtraCols([])
|
||
// Default to newest first on the source's first date field, if it has one
|
||
api.getSource(source)
|
||
.then(src => (src?.config?.fields || []).find(f => f.type === 'date')?.name || null)
|
||
.catch(() => null)
|
||
.then(dateCol => {
|
||
if (dateCol) setSort({ col: dateCol, dir: 'desc' })
|
||
load(0, dateCol, 'desc', [])
|
||
})
|
||
api.getOverrideKeys(source).then(setOverrideCols).catch(() => {})
|
||
api.getGlobalValues().then(setGlobalValues).catch(() => {})
|
||
setSelected(new Set())
|
||
setBulkDraft({})
|
||
setRowFilter('')
|
||
}, [source])
|
||
|
||
// Auto-select all rows matching the regex filter when it changes
|
||
useEffect(() => {
|
||
if (!rowFilter) return
|
||
let re = null
|
||
try { re = new RegExp(rowFilter, 'i') } catch { return }
|
||
const matches = rows.filter(r => {
|
||
for (const col of displayCols) {
|
||
const val = r[col]
|
||
if (val != null && re.test(String(val))) return true
|
||
}
|
||
return false
|
||
})
|
||
setSelected(new Set(matches.map(r => r.id)))
|
||
}, [rowFilter, rows])
|
||
|
||
async function load(off, col, dir, filt) {
|
||
setLoading(true)
|
||
try {
|
||
const active = (filt || []).filter(f => f.col && f.pattern)
|
||
const res = await api.getViewData(source, LIMIT, off, col, dir, active)
|
||
setExists(res.exists)
|
||
setRows(res.rows)
|
||
if (res.rows.length > 0) setCols(Object.keys(res.rows[0]))
|
||
} catch (err) {
|
||
setViewError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
function triggerLoad(off, col, dir, filt) {
|
||
clearTimeout(debounceRef.current)
|
||
debounceRef.current = setTimeout(() => load(off, col, dir, filt), 350)
|
||
}
|
||
|
||
function toggleSort(col) {
|
||
const next = sort.col === col
|
||
? { col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
|
||
: { col, dir: 'asc' }
|
||
setSort(next)
|
||
setOffset(0)
|
||
load(0, next.col, next.dir, filters)
|
||
}
|
||
|
||
function addFilter() {
|
||
// id is filterable but a poor default — start on the first data column
|
||
const visCols = gridCols(cols).filter(c => c !== 'id')
|
||
setFilters(f => [...f, { col: visCols[0] || '', pattern: '' }])
|
||
}
|
||
|
||
function removeFilter(i) {
|
||
const next = filters.filter((_, idx) => idx !== i)
|
||
setFilters(next)
|
||
setSelected(new Set())
|
||
setOffset(0)
|
||
load(0, sort.col, sort.dir, next)
|
||
}
|
||
|
||
function updateFilter(i, key, val) {
|
||
const next = filters.map((f, idx) => idx === i ? { ...f, [key]: val } : f)
|
||
setFilters(next)
|
||
setSelected(new Set())
|
||
setOffset(0)
|
||
triggerLoad(0, sort.col, sort.dir, next)
|
||
}
|
||
|
||
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); setSelected(new Set()); load(o, sort.col, sort.dir, filters) }
|
||
function next() { const o = offset + LIMIT; setOffset(o); setSelected(new Set()); load(o, sort.col, sort.dir, filters) }
|
||
|
||
async function openPanel(row) {
|
||
setPanelOpen(true)
|
||
setSelectedRow(row)
|
||
setSelectedRecord(null)
|
||
setOverrideDraft({})
|
||
setPanelMsg(null)
|
||
|
||
const id = row.id
|
||
if (!id) {
|
||
setPanelMsg({ text: 'No record ID — regenerate the view in Sources.', ok: false })
|
||
return
|
||
}
|
||
|
||
setPanelLoading(true)
|
||
try {
|
||
const rec = await api.getRecord(id)
|
||
setSelectedRecord(rec)
|
||
setOverrideDraft(rec.overrides || {})
|
||
} catch (err) {
|
||
setPanelMsg({ text: err.message, ok: false })
|
||
} finally {
|
||
setPanelLoading(false)
|
||
}
|
||
}
|
||
|
||
function closePanel() {
|
||
setPanelOpen(false)
|
||
setSelectedRow(null)
|
||
setSelectedRecord(null)
|
||
setOverrideDraft({})
|
||
setPanelMsg(null)
|
||
}
|
||
|
||
async function handleSaveOverrides() {
|
||
if (!selectedRecord) return
|
||
setPanelSaving(true)
|
||
setPanelMsg(null)
|
||
try {
|
||
const toSave = { ...overrideDraft }
|
||
const updated = await api.setRecordOverrides(selectedRecord.id, toSave)
|
||
setSelectedRecord(updated)
|
||
setOverrideDraft(updated.overrides || {})
|
||
// Merge any new cols from extraCols into overrideCols
|
||
setOverrideCols(prev => [...new Set([...prev, ...extraCols.filter(c => c.trim())])])
|
||
setExtraCols([])
|
||
setPanelMsg({ text: 'Saved.', ok: true })
|
||
load(offset, sort.col, sort.dir, filters)
|
||
} catch (err) {
|
||
setPanelMsg({ text: err.message, ok: false })
|
||
} finally {
|
||
setPanelSaving(false)
|
||
}
|
||
}
|
||
|
||
async function handleClearOverrides() {
|
||
if (!selectedRecord) return
|
||
setPanelSaving(true)
|
||
setPanelMsg(null)
|
||
try {
|
||
const updated = await api.clearRecordOverrides(selectedRecord.id)
|
||
setSelectedRecord(updated)
|
||
setOverrideDraft({})
|
||
setPanelMsg({ text: 'Cleared.', ok: true })
|
||
load(offset, sort.col, sort.dir, filters)
|
||
} catch (err) {
|
||
setPanelMsg({ text: err.message, ok: false })
|
||
} finally {
|
||
setPanelSaving(false)
|
||
}
|
||
}
|
||
|
||
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
|
||
|
||
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
|
||
const visCols = gridCols(cols)
|
||
|
||
// For bulk bar: only established override keys
|
||
const allOverrideCols = [...new Set([...overrideCols, ...extraCols])]
|
||
|
||
const savedOverrides = selectedRecord?.overrides || {}
|
||
const isDirty = Object.values(overrideDraft).some(v => String(v).trim())
|
||
|| extraCols.some(c => c.trim())
|
||
|| Object.keys(savedOverrides).some(k => !String(overrideDraft[k] ?? '').trim())
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 overflow-hidden">
|
||
<div className="flex-1 overflow-auto p-6 min-w-0">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h1 className="text-xl font-semibold text-gray-800">Records — {source}</h1>
|
||
{exists && rows.length > 0 && (
|
||
<span className="text-xs text-gray-400 font-mono">dfv.{source}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filter bar */}
|
||
{exists !== false && visCols.length > 0 && (
|
||
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
||
<span className="text-xs text-gray-400 font-medium mr-1">DB query:</span>
|
||
{filters.map((f, i) => (
|
||
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
|
||
<select
|
||
className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent"
|
||
value={f.col}
|
||
onChange={e => updateFilter(i, 'col', e.target.value)}
|
||
>
|
||
{visCols.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
<span className="text-xs text-gray-300 mx-0.5">~*</span>
|
||
<input
|
||
className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent"
|
||
placeholder="regex…"
|
||
value={f.pattern}
|
||
onChange={e => updateFilter(i, 'pattern', e.target.value)}
|
||
/>
|
||
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-gray-500 ml-1 leading-none">×</button>
|
||
</div>
|
||
))}
|
||
<button onClick={addFilter}
|
||
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1">
|
||
+ filter
|
||
</button>
|
||
{filters.length > 0 && (
|
||
<button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }}
|
||
className="text-xs text-gray-400 hover:text-red-500">clear</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk select + override bar */}
|
||
{exists && visCols.length > 0 && (
|
||
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
||
<span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span>
|
||
<input
|
||
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
|
||
rowFilter ? 'border-blue-300' : 'border-gray-200'
|
||
}`}
|
||
placeholder="regex on loaded rows…"
|
||
value={rowFilter}
|
||
onChange={e => setRowFilter(e.target.value)}
|
||
/>
|
||
{rowFilter && (
|
||
<span className="text-xs text-gray-400">{selected.size} of {rows.length} rows selected</span>
|
||
)}
|
||
{selected.size > 0 && (
|
||
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
|
||
{allOverrideCols.map(col => (
|
||
<AutocompleteInput
|
||
key={col}
|
||
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
|
||
placeholder={col}
|
||
value={bulkDraft[col] || ''}
|
||
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
|
||
suggestions={[...(globalValues[col] || [])].sort()}
|
||
/>
|
||
))}
|
||
<button
|
||
onClick={async () => {
|
||
const overrides = Object.fromEntries(
|
||
Object.entries(bulkDraft).filter(([, v]) => v.trim())
|
||
)
|
||
if (Object.keys(overrides).length === 0) return
|
||
if (selected.size === 0) return
|
||
setPanelSaving(true)
|
||
setPanelMsg(null)
|
||
try {
|
||
const res = await api.setBulkRecordOverrides(source, [...selected], overrides)
|
||
setSelected(new Set())
|
||
setBulkDraft({})
|
||
setPanelMsg({ text: `Updated ${res.updated} records.`, ok: true })
|
||
load(offset, sort.col, sort.dir, filters)
|
||
} catch (err) {
|
||
setPanelMsg({ text: err.message, ok: false })
|
||
} finally {
|
||
setPanelSaving(false)
|
||
}
|
||
}}
|
||
disabled={panelSaving || selected.size === 0 || Object.values(bulkDraft).every(v => !v.trim())}
|
||
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40 whitespace-nowrap"
|
||
>
|
||
Apply to {selected.size}
|
||
</button>
|
||
<button
|
||
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
|
||
className="text-xs text-blue-400 hover:text-blue-600"
|
||
>
|
||
cancel
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{loading && <p className="text-sm text-gray-400">Loading…</p>}
|
||
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} — check field types in Sources.</p>}
|
||
{!loading && exists === false && (
|
||
<p className="text-sm text-gray-400">
|
||
No view generated yet. Go to <span className="font-medium text-gray-600">Sources</span>, check fields as <span className="font-medium text-gray-600">In view</span>, then click <span className="font-medium text-gray-600">Generate view</span>.
|
||
</p>
|
||
)}
|
||
{!loading && exists && rows.length === 0 && (
|
||
<p className="text-sm text-gray-400">
|
||
{filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'}
|
||
</p>
|
||
)}
|
||
|
||
{!loading && exists && rows.length > 0 && (
|
||
<>
|
||
<div className="bg-white border border-gray-200 rounded overflow-auto mb-4">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
|
||
<th className="px-2 py-2 w-8">
|
||
<input
|
||
type="checkbox"
|
||
className="cursor-pointer"
|
||
checked={rows.length > 0 && rows.every(r => selected.has(r.id))}
|
||
onChange={e => {
|
||
if (e.target.checked) setSelected(new Set(rows.map(r => r.id)))
|
||
else setSelected(new Set())
|
||
}}
|
||
/>
|
||
</th>
|
||
{displayCols.map(col => {
|
||
const active = sort.col === col
|
||
return (
|
||
<th key={col} onClick={() => toggleSort(col)}
|
||
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600">
|
||
{col}
|
||
<span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
|
||
</th>
|
||
)
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, i) => {
|
||
const isOverridden = row._overridden
|
||
const isRowSelected = selected.has(row.id)
|
||
const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id
|
||
return (
|
||
<tr key={i} onClick={() => openPanel(row)}
|
||
className={`border-t border-gray-50 cursor-pointer transition-colors
|
||
${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}>
|
||
<td className="px-2 py-2">
|
||
<input
|
||
type="checkbox"
|
||
className="cursor-pointer"
|
||
checked={isRowSelected}
|
||
onChange={e => {
|
||
e.stopPropagation()
|
||
setSelected(s => { const n = new Set(s); n.has(row.id) ? n.delete(row.id) : n.add(row.id); return n })
|
||
}}
|
||
/>
|
||
</td>
|
||
{displayCols.map((col, j) => {
|
||
const formatted = formatVal(row[col])
|
||
return (
|
||
<td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate">
|
||
{formatted === null ? <span className="text-gray-300">—</span> : formatted}
|
||
</td>
|
||
)
|
||
})}
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 text-sm text-gray-500">
|
||
<button onClick={prev} disabled={offset === 0}
|
||
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">← Prev</button>
|
||
<span>{offset + 1}–{offset + rows.length}</span>
|
||
<button onClick={next} disabled={rows.length < LIMIT}
|
||
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next →</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Panel */}
|
||
{panelOpen && (
|
||
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0">
|
||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100">
|
||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span>
|
||
<button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button>
|
||
</div>
|
||
|
||
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading…</p>}
|
||
|
||
{selectedRecord && !panelLoading && (
|
||
<div className="flex-1 overflow-y-auto flex flex-col min-h-0">
|
||
{panelMsg && (
|
||
<div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}>
|
||
{panelMsg.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Raw fields — read only */}
|
||
<div className="border-b border-gray-100">
|
||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span>
|
||
</div>
|
||
{Object.entries(selectedRecord.data || {}).map(([field, val]) => (
|
||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
|
||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
||
<span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Transformed fields — read only delta */}
|
||
<div className="border-b border-gray-100">
|
||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span>
|
||
</div>
|
||
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0
|
||
? <div className="px-3 py-2 text-xs text-gray-300">No rule output yet.</div>
|
||
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
|
||
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0">
|
||
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span>
|
||
<span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300">—</span>}</span>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
|
||
{/* Overrides — editable */}
|
||
<div className="flex-1 border-b border-gray-100">
|
||
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100">
|
||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span>
|
||
<button
|
||
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none"
|
||
title="Add field">+</button>
|
||
</div>
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
{[...new Set([
|
||
...Object.keys(selectedRecord.transformed || {}),
|
||
...Object.keys(selectedRecord.overrides || {}),
|
||
...overrideCols
|
||
])].filter(k => !HIDDEN_COLS.has(k)).map(col => {
|
||
const override = overrideDraft[col] ?? ''
|
||
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
|
||
const suggestions = [...(globalValues[col] || [])].sort()
|
||
return (
|
||
<tr key={col} className="border-t border-gray-50">
|
||
<td className="px-3 py-1.5 w-28 shrink-0">
|
||
<span className="font-mono text-gray-500 truncate block">{col}</span>
|
||
</td>
|
||
<td className="px-1 py-1.5">
|
||
<AutocompleteInput
|
||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||
override ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
||
}`}
|
||
value={override}
|
||
placeholder={placeholder}
|
||
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
||
onEnter={handleSaveOverrides}
|
||
suggestions={suggestions}
|
||
/>
|
||
</td>
|
||
<td className="pr-2 text-center w-6">
|
||
{override && (
|
||
<button
|
||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{extraCols.map((col, i) => {
|
||
const val = overrideDraft[col] ?? ''
|
||
const suggestions = [...(globalValues[col] || [])].sort()
|
||
return (
|
||
<tr key={`extra-${i}`} className="border-t border-gray-50">
|
||
<td className="px-3 py-1.5 w-28 shrink-0">
|
||
<input
|
||
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
|
||
value={col}
|
||
placeholder="field name"
|
||
onChange={e => {
|
||
const newName = e.target.value
|
||
setExtraCols(ec => { const c = [...ec]; c[i] = newName; return c })
|
||
if (val) setOverrideDraft(d => {
|
||
const n = { ...d }
|
||
delete n[col]
|
||
if (newName) n[newName] = val
|
||
return n
|
||
})
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="px-1 py-1.5">
|
||
<AutocompleteInput
|
||
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
|
||
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600'
|
||
}`}
|
||
value={val}
|
||
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
|
||
onEnter={handleSaveOverrides}
|
||
suggestions={suggestions}
|
||
/>
|
||
</td>
|
||
<td className="pr-2 text-center w-6">
|
||
{val && (
|
||
<button
|
||
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
|
||
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0">
|
||
<button
|
||
onClick={handleSaveOverrides}
|
||
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'}
|
||
</button>
|
||
{selectedRecord.overrides && Object.keys(selectedRecord.overrides).length > 0 && (
|
||
<button
|
||
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
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|