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 (
{ 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 && (
{filtered.map((s, i) => (
{ e.preventDefault(); select(s) }}>{s}
))}
)}
) } 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
Select a source first.
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 (

Records — {source}

{exists && rows.length > 0 && ( dfv.{source} )}
{/* Filter bar */} {exists !== false && visCols.length > 0 && (
DB query: {filters.map((f, i) => (
~* updateFilter(i, 'pattern', e.target.value)} />
))} {filters.length > 0 && ( )}
)} {/* Bulk select + override bar */} {exists && visCols.length > 0 && (
Bulk select: setRowFilter(e.target.value)} /> {rowFilter && ( {selected.size} of {rows.length} rows selected )} {selected.size > 0 && (
{allOverrideCols.map(col => ( setBulkDraft(d => ({ ...d, [col]: v }))} suggestions={[...(globalValues[col] || [])].sort()} /> ))}
)}
)} {loading &&

Loading…

} {!loading && viewError &&

View error: {viewError} — check field types in Sources.

} {!loading && exists === false && (

No view generated yet. Go to Sources, check fields as In view, then click Generate view.

)} {!loading && exists && rows.length === 0 && (

{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.'}

)} {!loading && exists && rows.length > 0 && ( <>
{displayCols.map(col => { const active = sort.col === col return ( ) })} {rows.map((row, i) => { const isOverridden = row._overridden const isRowSelected = selected.has(row.id) const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id return ( 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'}`}> {displayCols.map((col, j) => { const formatted = formatVal(row[col]) return ( ) })} ) })}
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()) }} /> toggleSort(col)} className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600"> {col} {active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}
{ e.stopPropagation() setSelected(s => { const n = new Set(s); n.has(row.id) ? n.delete(row.id) : n.add(row.id); return n }) }} /> {formatted === null ? : formatted}
{offset + 1}–{offset + rows.length}
)}
{/* Panel */} {panelOpen && (
Record
{panelLoading &&

Loading…

} {selectedRecord && !panelLoading && (
{panelMsg && (
{panelMsg.text}
)} {/* Raw fields — read only */}
Raw
{Object.entries(selectedRecord.data || {}).map(([field, val]) => (
{field} {formatVal(val) ?? }
))}
{/* Transformed fields — read only delta */}
Transformed
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0 ?
No rule output yet.
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
{field} {formatVal(val) ?? }
)) }
{/* Overrides — editable */}
Overrides
{[...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 ( ) })} {extraCols.map((col, i) => { const val = overrideDraft[col] ?? '' const suggestions = [...(globalValues[col] || [])].sort() return ( ) })}
{col} setOverrideDraft(d => ({ ...d, [col]: v }))} onEnter={handleSaveOverrides} suggestions={suggestions} /> {override && ( )}
{ 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 }) }} /> setOverrideDraft(d => ({ ...d, [col]: v }))} onEnter={handleSaveOverrides} suggestions={suggestions} /> {val && ( )}
{selectedRecord.overrides && Object.keys(selectedRecord.overrides).length > 0 && ( )}
)}
)}
) }