531 lines
22 KiB
JavaScript
531 lines
22 KiB
JavaScript
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>
|
||
)
|
||
}
|