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 [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} onFocus={onFocus} 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+-]+)?$/ const HIDDEN_COLS = new Set(['id', '_overridden']) 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([]) const debounceRef = useRef(null) const LIMIT = 100 // Override panel const [panelOpen, setPanelOpen] = useState(false) 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) useEffect(() => { if (!source) return setOffset(0) setSort({ col: null, dir: 'asc' }) setFilters([]) setViewError(null) setSelectedRecord(null) setSelectedRow(null) setPanelOpen(false) load(0, null, 'asc', []) }, [source]) 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() { const visCols = cols.filter(c => !HIDDEN_COLS.has(c)) setFilters(f => [...f, { col: visCols[0] || '', pattern: '' }]) } function removeFilter(i) { const next = filters.filter((_, idx) => idx !== i) setFilters(next) 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) setOffset(0) triggerLoad(0, sort.col, sort.dir, next) } function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o, sort.col, sort.dir, filters) } function next() { const o = offset + LIMIT; setOffset(o); load(o, sort.col, sort.dir, filters) } async function openPanel(row) { // Open panel immediately, then load full record async setPanelOpen(true) setSelectedRow(row) setSelectedRecord(null) setOverrideDraft({}) setExtraFields([]) 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({}) setExtraFields([]) setPanelMsg(null) } async function handleSaveOverrides() { if (!selectedRecord) return setPanelSaving(true) setPanelMsg(null) try { 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 }) // Refresh the row in the table setRows(rs => rs.map(r => r.id === updated.id ? { ...r, _overridden: updated.overrides != null } : r )) } 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: 'Overrides cleared. Transformed values restored.', ok: true }) setRows(rs => rs.map(r => r.id === updated.id ? { ...r, _overridden: false } : r)) } catch (err) { setPanelMsg({ text: err.message, ok: false }) } finally { setPanelSaving(false) } } if (!source) return
Select a source first.
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 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 (

Records — {source}

{exists && rows.length > 0 && ( dfv.{source} )}
{/* Filter bar */} {exists !== false && visCols.length > 0 && (
{filters.map((f, i) => (
~* updateFilter(i, 'pattern', e.target.value)} />
))} {filters.length > 0 && ( )}
)} {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 isSelected = selectedRow?.id != null && selectedRow.id === row.id return ( openPanel(row)} className={`border-t border-gray-50 cursor-pointer transition-colors ${isSelected ? '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 ( ) })} ) })}
toggleSort(col)} className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600" > {col} {active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}
{formatted === null ? : formatted}
{offset + 1}–{offset + rows.length}
)}
{/* Override panel */} {panelOpen && (
Override
{panelLoading &&

Loading…

} {selectedRecord && !panelLoading && (
{panelMsg && (
{panelMsg.text}
)} {transformedFields.map(field => { const currentVal = selectedRecord.transformed[field] const isOverridden = field in overrideDraft const suggestions = [...(valuesByField[field] || [])].sort() return ( ) })} {/* New field rows */} {extraFields.map((ef, i) => ( ))}
field value
{field} setOverrideDraft(d => ({ ...d, [field]: v }))} onEnter={handleSaveOverrides} suggestions={suggestions} onFocus={() => { if (!(field in overrideDraft)) setOverrideDraft(d => ({ ...d, [field]: String(currentVal ?? '') })) }} /> {isOverridden && ( )}
setExtraFields(fs => fs.map((f, j) => j === i ? { ...f, field: v } : f))} suggestions={fieldSuggestions} /> setExtraFields(fs => fs.map((f, j) => j === i ? { ...f, value: v } : f))} suggestions={ef.field ? [...(valuesByField[ef.field] || [])].sort() : []} onEnter={handleSaveOverrides} />
{selectedRecord.overrides && ( )}
)}
)}
) }