dataflow/ui/src/pages/Records.jsx
2026-04-25 09:59:14 -04:00

531 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<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}
onFocus={onFocus}
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+-]+)?$/
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 <div className="p-6 text-sm text-gray-400">Select a source first.</div>
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 (
<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">
{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>
)}
{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">
{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 isSelected = selectedRow?.id != null && selectedRow.id === row.id
return (
<tr
key={i}
onClick={() => 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 (
<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>
{/* Override 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">Override</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>
)}
<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>
)
})}
{/* 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
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 overrides'}
</button>
{selectedRecord.overrides && (
<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 all
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}