Redesign mappings page: single grid, sticky controls, rule-gated loading

- Replace three-tab layout with single unified grid and filter buttons
  (All/Unmapped/Mapped with counts) in a sticky top control bar
- Require rule selection before loading any data
- Move source label, rule selector, filter, Save All, Export/Import into
  sticky header that stays visible while scrolling
- Add inline delete (×) per mapped row — reverts to unmapped rather than
  removing the row from view
- Simplify component state: drop separate unmapped/mapped state, derive
  everything from allValues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-05 11:14:24 -04:00
parent dcac6def87
commit 63b1634b40

View File

@ -10,165 +10,108 @@ function displayValue(v) {
return String(v ?? '') return String(v ?? '')
} }
function SortHeader({ ruleName, col, label, sortBy, onSort, className = '' }) { function SortHeader({ col, label, sortBy, onSort, className = '' }) {
const s = sortBy[ruleName] const active = sortBy?.col === col
const active = s?.col === col
return ( return (
<th <th
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`} className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
onClick={() => onSort(ruleName, col)} onClick={() => onSort(col)}
> >
{label} {label}
<span className="ml-1 text-gray-300">{active ? (s.dir === 'asc' ? '↑' : '↓') : '↕'}</span> <span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
</th> </th>
) )
} }
export default function Mappings({ source }) { export default function Mappings({ source }) {
const [tab, setTab] = useState('unmapped')
const [rules, setRules] = useState([]) const [rules, setRules] = useState([])
const [selectedRule, setSelectedRule] = useState('') const [selectedRule, setSelectedRule] = useState('')
const [unmapped, setUnmapped] = useState([]) const [allValues, setAllValues] = useState([])
const [mapped, setMapped] = useState([]) const [filter, setFilter] = useState('all')
// drafts[valueKey][colKey] = value
const [drafts, setDrafts] = useState({}) const [drafts, setDrafts] = useState({})
// extraCols[ruleName] = [colName, ...] user-added columns const [extraCols, setExtraCols] = useState([])
const [extraCols, setExtraCols] = useState({})
const [saving, setSaving] = useState({}) const [saving, setSaving] = useState({})
const [sampleOpen, setSampleOpen] = useState({}) const [sampleOpen, setSampleOpen] = useState({})
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [editingId, setEditingId] = useState(null)
const [editDrafts, setEditDrafts] = useState({})
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
// sortBy[ruleName] = { col, dir: 'asc'|'desc' } const [sortBy, setSortBy] = useState(null)
const [sortBy, setSortBy] = useState({})
const [allValues, setAllValues] = useState([])
useEffect(() => { useEffect(() => {
if (!source) return if (!source) return
api.getRules(source).then(r => { api.getRules(source).then(r => setRules(r)).catch(() => {})
setRules(r)
if (r.length > 0 && !selectedRule) setSelectedRule(r[0].name)
}).catch(() => {})
}, [source]) }, [source])
useEffect(() => { useEffect(() => {
if (!source) return if (!source || !selectedRule) {
setAllValues([])
return
}
setLoading(true) setLoading(true)
const rule = selectedRule || undefined api.getAllValues(source, selectedRule)
Promise.all([ .then(a => {
api.getUnmapped(source, rule), setAllValues(a)
api.getMappings(source, rule), setDrafts({})
api.getAllValues(source, rule) setExtraCols([])
]).then(([u, m, a]) => { })
setUnmapped(u) .catch(() => {})
setMapped(m) .finally(() => setLoading(false))
setAllValues(a)
setDrafts({})
setExtraCols({})
}).catch(() => {}).finally(() => setLoading(false))
}, [source, selectedRule]) }, [source, selectedRule])
// Derive existing output key columns from mapped values, per rule // Derive output columns and datalist suggestions from mapped rows
const existingColsByRule = {} const existingCols = []
// Distinct values already used per rule+column (for datalist suggestions) const valuesByCol = {}
const valuesByRuleCol = {} allValues.forEach(row => {
mapped.forEach(m => { if (!row.is_mapped) return
if (!existingColsByRule[m.rule_name]) existingColsByRule[m.rule_name] = [] Object.entries(row.output || {}).forEach(([k, v]) => {
Object.entries(m.output || {}).forEach(([k, v]) => { if (!existingCols.includes(k)) existingCols.push(k)
if (!existingColsByRule[m.rule_name].includes(k)) if (!valuesByCol[k]) valuesByCol[k] = new Set()
existingColsByRule[m.rule_name].push(k) valuesByCol[k].add(String(v))
if (!valuesByRuleCol[m.rule_name]) valuesByRuleCol[m.rule_name] = {}
if (!valuesByRuleCol[m.rule_name][k]) valuesByRuleCol[m.rule_name][k] = new Set()
valuesByRuleCol[m.rule_name][k].add(String(v))
}) })
}) })
const cols = [...existingCols, ...extraCols]
function toggleSort(ruleName, col) { const unmappedCount = allValues.filter(r => !r.is_mapped).length
const mappedCount = allValues.filter(r => r.is_mapped).length
const filteredRows = filter === 'unmapped'
? allValues.filter(r => !r.is_mapped)
: filter === 'mapped'
? allValues.filter(r => r.is_mapped)
: allValues
function toggleSort(col) {
setSortBy(s => { setSortBy(s => {
const cur = s[ruleName] if (s?.col === col) return { col, dir: s.dir === 'asc' ? 'desc' : 'asc' }
if (cur?.col === col) return { ...s, [ruleName]: { col, dir: cur.dir === 'asc' ? 'desc' : 'asc' } } return { col, dir: 'asc' }
return { ...s, [ruleName]: { col, dir: 'asc' } }
}) })
} }
function sortRows(rows, ruleName, getCellFn) { function sortedRows(rows) {
const s = sortBy[ruleName] if (!sortBy) return rows
if (!s) return rows
return [...rows].sort((a, b) => { return [...rows].sort((a, b) => {
if (s.col === 'count') { if (sortBy.col === 'count') {
const av = a.record_count ?? 0 const av = Number(a.record_count) || 0
const bv = b.record_count ?? 0 const bv = Number(b.record_count) || 0
return s.dir === 'asc' ? av - bv : bv - av return sortBy.dir === 'asc' ? av - bv : bv - av
} }
let av, bv let av, bv
if (s.col === 'input_value') { if (sortBy.col === 'input_value') {
av = displayValue(a.extracted_value) av = displayValue(a.extracted_value)
bv = displayValue(b.extracted_value) bv = displayValue(b.extracted_value)
} else { } else {
av = String(getCellFn(a, s.col) ?? '') av = a.is_mapped ? String(a.output?.[sortBy.col] ?? '') : ''
bv = String(getCellFn(b, s.col) ?? '') bv = b.is_mapped ? String(b.output?.[sortBy.col] ?? '') : ''
} }
return s.dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av) return sortBy.dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
}) })
} }
function colsForRule(ruleName, outputField) {
const existing = existingColsByRule[ruleName] || [outputField].filter(Boolean)
const extra = extraCols[ruleName] || []
return [...existing, ...extra]
}
function getCellValue(extractedValue, col) {
return drafts[valueKey(extractedValue)]?.[col] || ''
}
function setCellValue(extractedValue, col, value) { function setCellValue(extractedValue, col, value) {
const k = valueKey(extractedValue) const k = valueKey(extractedValue)
setDrafts(d => ({ ...d, [k]: { ...(d[k] || {}), [col]: value } })) setDrafts(d => ({ ...d, [k]: { ...(d[k] || {}), [col]: value } }))
} }
function addCol(ruleName) { async function saveRow(row) {
setExtraCols(e => ({ ...e, [ruleName]: [...(e[ruleName] || []), ''] }))
}
function setExtraColName(ruleName, idx, name) {
setExtraCols(e => {
const cols = [...(e[ruleName] || [])]
cols[idx] = name
return { ...e, [ruleName]: cols }
})
}
async function saveMapping(row, cols) {
const k = valueKey(row.extracted_value)
const output = Object.fromEntries(
cols.map(col => [col, getCellValue(row.extracted_value, col)])
.filter(([, v]) => v.trim())
)
if (Object.keys(output).length === 0) return
setSaving(s => ({ ...s, [k]: true }))
try {
await api.createMapping({
source_name: source,
rule_name: row.rule_name,
input_value: row.extracted_value,
output
})
setUnmapped(u => u.filter(x => valueKey(x.extracted_value) !== k))
setMapped(m => [...m, { rule_name: row.rule_name, input_value: row.extracted_value, output }])
setDrafts(d => { const n = { ...d }; delete n[k]; return n })
} catch (err) {
alert(err.message)
} finally {
setSaving(s => ({ ...s, [k]: false }))
}
}
// Save for the All tab create if unmapped, update if already mapped
// Merges draft values over existing mapping values so unedited fields are preserved
async function saveAllRow(row, cols) {
const k = valueKey(row.extracted_value) const k = valueKey(row.extracted_value)
const output = Object.fromEntries( const output = Object.fromEntries(
cols.map(col => { cols.map(col => {
@ -184,9 +127,7 @@ export default function Mappings({ source }) {
if (row.is_mapped && row.mapping_id) { if (row.is_mapped && row.mapping_id) {
const updated = await api.updateMapping(row.mapping_id, { output }) const updated = await api.updateMapping(row.mapping_id, { output })
setAllValues(av => av.map(x => setAllValues(av => av.map(x =>
x.rule_name === row.rule_name && valueKey(x.extracted_value) === k valueKey(x.extracted_value) === k ? { ...x, output: updated.output } : x
? { ...x, output: updated.output }
: x
)) ))
} else { } else {
await api.createMapping({ await api.createMapping({
@ -196,11 +137,8 @@ export default function Mappings({ source }) {
output output
}) })
setAllValues(av => av.map(x => setAllValues(av => av.map(x =>
x.rule_name === row.rule_name && valueKey(x.extracted_value) === k valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, output } : x
? { ...x, is_mapped: true, output }
: x
)) ))
setUnmapped(u => u.filter(x => valueKey(x.extracted_value) !== k))
} }
setDrafts(d => { const n = { ...d }; delete n[k]; return n }) setDrafts(d => { const n = { ...d }; delete n[k]; return n })
} catch (err) { } catch (err) {
@ -210,12 +148,31 @@ export default function Mappings({ source }) {
} }
} }
async function saveAllDrafts(rows, cols) { const dirtyCount = filteredRows.filter(row => {
const dirty = rows.filter(row => { const k = valueKey(row.extracted_value)
return drafts[k] && Object.keys(drafts[k]).length > 0
}).length
async function saveAllPending() {
const dirty = filteredRows.filter(row => {
const k = valueKey(row.extracted_value) const k = valueKey(row.extracted_value)
return drafts[k] && Object.keys(drafts[k]).length > 0 return drafts[k] && Object.keys(drafts[k]).length > 0
}) })
await Promise.all(dirty.map(row => saveAllRow(row, cols))) await Promise.all(dirty.map(row => saveRow(row)))
}
async function deleteRow(row) {
if (!row.mapping_id) return
try {
await api.deleteMapping(row.mapping_id)
setAllValues(av => av.map(x =>
valueKey(x.extracted_value) === valueKey(row.extracted_value)
? { ...x, is_mapped: false, mapping_id: null, output: null }
: x
))
} catch (err) {
alert(err.message)
}
} }
async function handleImportCSV(e) { async function handleImportCSV(e) {
@ -226,13 +183,10 @@ export default function Mappings({ source }) {
try { try {
const result = await api.importMappingsCSV(source, file) const result = await api.importMappingsCSV(source, file)
alert(`Imported ${result.count} mapping${result.count !== 1 ? 's' : ''}.`) alert(`Imported ${result.count} mapping${result.count !== 1 ? 's' : ''}.`)
const rule = selectedRule || undefined const a = await api.getAllValues(source, selectedRule)
const [u, m, a] = await Promise.all([api.getUnmapped(source, rule), api.getMappings(source, rule), api.getAllValues(source, rule)])
setUnmapped(u)
setMapped(m)
setAllValues(a) setAllValues(a)
setDrafts({}) setDrafts({})
setExtraCols({}) setExtraCols([])
} catch (err) { } catch (err) {
alert(err.message) alert(err.message)
} finally { } finally {
@ -240,76 +194,61 @@ export default function Mappings({ source }) {
} }
} }
async function deleteMapping(id) {
try {
await api.deleteMapping(id)
setMapped(m => m.filter(x => x.id !== id))
} catch (err) {
alert(err.message)
}
}
function startEdit(m) {
const pairs = Object.entries(m.output).map(([key, value]) => ({ key, value }))
setEditDrafts(d => ({ ...d, [m.id]: pairs.length ? pairs : [{ key: '', value: '' }] }))
setEditingId(m.id)
}
function updateEditKey(id, index, newKey) {
setEditDrafts(d => {
const pairs = d[id].map((p, i) => i === index ? { ...p, key: newKey } : p)
return { ...d, [id]: pairs }
})
}
function updateEditValue(id, index, newValue) {
setEditDrafts(d => {
const pairs = d[id].map((p, i) => i === index ? { ...p, value: newValue } : p)
return { ...d, [id]: pairs }
})
}
function addEditPair(id) {
setEditDrafts(d => ({ ...d, [id]: [...d[id], { key: '', value: '' }] }))
}
async function saveEdit(m) {
const pairs = editDrafts[m.id] || []
const output = Object.fromEntries(pairs.filter(p => p.key && p.value).map(p => [p.key, p.value]))
if (Object.keys(output).length === 0) return
setSaving(s => ({ ...s, [m.id]: true }))
try {
const updated = await api.updateMapping(m.id, { output })
setMapped(ms => ms.map(x => x.id === m.id ? updated : x))
setEditingId(null)
} catch (err) {
alert(err.message)
} finally {
setSaving(s => ({ ...s, [m.id]: false }))
}
}
// Group unmapped rows by rule
const unmappedByRule = {}
unmapped.forEach(row => {
if (!unmappedByRule[row.rule_name]) unmappedByRule[row.rule_name] = []
unmappedByRule[row.rule_name].push(row)
})
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const displayRows = sortedRows(filteredRows)
return ( return (
<div className="p-6"> <div>
<div className="flex items-center justify-between mb-4"> {/* Sticky control bar */}
<h1 className="text-xl font-semibold text-gray-800">Mappings {source}</h1> <div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2"> <span className="text-sm font-medium text-gray-700">{source}</span>
<a
href={api.exportMappingsUrl(source, selectedRule)} <select
download className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600" value={selectedRule}
onChange={e => setSelectedRule(e.target.value)}
>
<option value="">Select a rule</option>
{rules.map(r => <option key={r.name} value={r.name}>{r.name}</option>)}
</select>
{selectedRule && (
<div className="flex bg-gray-100 rounded p-0.5">
{[
{ key: 'all', label: `All (${allValues.length})` },
{ key: 'unmapped', label: `Unmapped (${unmappedCount})` },
{ key: 'mapped', label: `Mapped (${mappedCount})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setFilter(key)}
className={`text-xs px-3 py-1 rounded transition-colors ${
filter === key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
}`}>
{label}
</button>
))}
</div>
)}
{dirtyCount > 0 && (
<button
onClick={saveAllPending}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
> >
Export TSV Save all ({dirtyCount})
</a> </button>
)}
<div className="ml-auto flex items-center gap-2">
{selectedRule && (
<a
href={api.exportMappingsUrl(source, selectedRule)}
download
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600"
>
Export TSV
</a>
)}
<label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}> <label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
{importing ? 'Importing…' : 'Import TSV'} {importing ? 'Importing…' : 'Import TSV'}
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} /> <input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
@ -317,411 +256,156 @@ export default function Mappings({ source }) {
</div> </div>
</div> </div>
{/* Rule filter + tabs */} {/* Content */}
<div className="flex items-center gap-3 mb-4"> <div className="p-6">
<select {!selectedRule && (
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400" <p className="text-sm text-gray-400">Select a rule to view mappings.</p>
value={selectedRule} )}
onChange={e => setSelectedRule(e.target.value)} {selectedRule && loading && (
> <p className="text-sm text-gray-400">Loading</p>
<option value="">All rules</option> )}
{rules.map(r => <option key={r.name} value={r.name}>{r.name}</option>)} {selectedRule && !loading && allValues.length === 0 && (
</select> <p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
)}
<div className="flex bg-gray-100 rounded p-0.5"> {selectedRule && !loading && allValues.length > 0 && (
{['unmapped', 'mapped', 'all'].map(t => ( <div className="overflow-x-auto">
<button key={t} onClick={() => setTab(t)} {cols.map(col => (
className={`text-sm px-3 py-1 rounded transition-colors ${ <datalist key={col} id={`dl-${col}`}>
tab === t ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500' {[...(valuesByCol[col] || [])].sort().map(v => (
}`}> <option key={v} value={v} />
{t === 'unmapped' ? `Unmapped${unmapped.length ? ` (${unmapped.length})` : ''}` : t === 'mapped' ? 'Mapped' : 'All'} ))}
</button> </datalist>
))} ))}
</div> <table className="w-full text-xs bg-white border border-gray-200 rounded">
</div> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
{loading && <p className="text-sm text-gray-400">Loading</p>} <SortHeader col="input_value" label="input_value" sortBy={sortBy} onSort={toggleSort} />
<SortHeader col="count" label="count" sortBy={sortBy} onSort={toggleSort} className="text-right" />
{/* Unmapped tab — spreadsheet layout */} {existingCols.map(col => (
{!loading && tab === 'unmapped' && ( <SortHeader key={col} col={col} label={col} sortBy={sortBy} onSort={toggleSort} />
<>
{unmapped.length === 0
? <p className="text-sm text-gray-400">No unmapped values. Run a transform first, or all values are mapped.</p>
: Object.entries(unmappedByRule).map(([ruleName, rows]) => {
const cols = colsForRule(ruleName, rows[0]?.output_field)
const extra = extraCols[ruleName] || []
const existingCount = cols.length - extra.length
return (
<div key={ruleName} className="mb-6 overflow-x-auto">
{/* Datalists for column value suggestions */}
{cols.map(col => (
<datalist key={col} id={`dl-${ruleName}-${col}`}>
{[...(valuesByRuleCol[ruleName]?.[col] || [])].sort().map(v => (
<option key={v} value={v} />
))}
</datalist>
))}
<table className="w-full text-xs bg-white border border-gray-200 rounded">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={ruleName} col="rule" label="rule" />
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={ruleName} col="input_value" label="input_value" />
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={ruleName} col="count" label="count" className="text-right" />
{cols.slice(0, existingCount).map(col => (
<SortHeader sortBy={sortBy} onSort={toggleSort} key={col} ruleName={ruleName} col={col} label={col} />
))}
{extra.map((col, idx) => (
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
<input
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
value={col}
placeholder="new key"
onChange={e => setExtraColName(ruleName, idx, e.target.value)}
/>
</th>
))}
<th className="px-2 py-2">
<button onClick={() => addCol(ruleName)} className="text-gray-300 hover:text-gray-500 font-normal" title="Add column">+</button>
</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{sortRows(rows, ruleName, (row, col) => getCellValue(row.extracted_value, col)).map(row => {
const k = valueKey(row.extracted_value)
const isSaving = saving[k]
const sampleKey = `${ruleName}:${k}`
const samples = row.sample ? (Array.isArray(row.sample) ? row.sample : [row.sample]) : []
return (
<>
<tr key={k} className="border-t border-gray-50 hover:bg-gray-50">
<td className="px-3 py-1.5 text-gray-400">{ruleName}</td>
<td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
{cols.map(col => (
<td key={col} className="px-3 py-1.5">
<input
list={`dl-${ruleName}-${col}`}
className="border border-gray-200 rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400"
value={getCellValue(row.extracted_value, col)}
onChange={e => setCellValue(row.extracted_value, col, e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveMapping(row, cols)}
/>
</td>
))}
{/* Empty cell under the + button */}
<td />
<td className="px-3 py-1.5">
{samples.length > 0 && (
<button
className="text-blue-400 hover:text-blue-600 whitespace-nowrap"
onClick={() => setSampleOpen(s => ({ ...s, [sampleKey]: !s[sampleKey] }))}
>
{sampleOpen[sampleKey] ? 'hide' : 'show'}
</button>
)}
</td>
<td className="px-3 py-1.5">
<button
onClick={() => saveMapping(row, cols)}
disabled={isSaving}
className="bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
>
{isSaving ? '…' : 'Save'}
</button>
</td>
</tr>
{sampleOpen[sampleKey] && (() => {
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
return (
<tr className="border-t border-gray-50 bg-gray-50">
<td colSpan={4 + cols.length + 3} className="px-3 py-2">
<table className="w-full text-xs border border-gray-100 rounded bg-white">
<thead>
<tr className="bg-gray-50 border-b border-gray-100">
{sampleCols.map(c => (
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{samples.map((rec, i) => (
<tr key={i} className="border-t border-gray-50">
{sampleCols.map(c => (
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">{rec[c] != null ? String(rec[c]) : ''}</td>
))}
</tr>
))}
</tbody>
</table>
</td>
</tr>
)
})()}
</>
)
})}
</tbody>
</table>
</div>
)
})
}
</>
)}
{/* All tab — single SQL query, all extracted values with mapping status */}
{!loading && tab === 'all' && (() => {
// Derive columns and datalist suggestions from mapped rows in allValues
const allColsByRule = {}
const allValuesByRuleCol = {}
allValues.forEach(row => {
if (!row.is_mapped) return
if (!allColsByRule[row.rule_name]) allColsByRule[row.rule_name] = []
Object.entries(row.output || {}).forEach(([k, v]) => {
if (!allColsByRule[row.rule_name].includes(k))
allColsByRule[row.rule_name].push(k)
if (!allValuesByRuleCol[row.rule_name]) allValuesByRuleCol[row.rule_name] = {}
if (!allValuesByRuleCol[row.rule_name][k]) allValuesByRuleCol[row.rule_name][k] = new Set()
allValuesByRuleCol[row.rule_name][k].add(String(v))
})
})
// Group rows by rule
const byRule = {}
allValues.forEach(row => {
if (!byRule[row.rule_name]) byRule[row.rule_name] = []
byRule[row.rule_name].push(row)
})
if (Object.keys(byRule).length === 0)
return <p className="text-sm text-gray-400">No extracted values. Run a transform first.</p>
return Object.entries(byRule).map(([ruleName, rows]) => {
const existing = allColsByRule[ruleName] || [rows[0]?.output_field].filter(Boolean)
const extra = extraCols[ruleName] || []
const cols = [...existing, ...extra]
const existingCount = cols.length - extra.length
const dirtyCount = rows.filter(row => {
const k = valueKey(row.extracted_value)
return drafts[k] && Object.keys(drafts[k]).length > 0
}).length
return (
<div key={ruleName} className="mb-6 overflow-x-auto">
{dirtyCount > 0 && (
<div className="flex justify-end mb-1">
<button
onClick={() => saveAllDrafts(rows, cols)}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700"
>
Save all ({dirtyCount})
</button>
</div>
)}
{cols.map(col => (
<datalist key={col} id={`dl-all-${ruleName}-${col}`}>
{[...(allValuesByRuleCol[ruleName]?.[col] || [])].sort().map(v => (
<option key={v} value={v} />
))} ))}
</datalist> {extraCols.map((col, idx) => (
))} <th key={`extra-${idx}`} className="px-3 py-2 font-medium">
<table className="w-full text-xs bg-white border border-gray-200 rounded"> <input
<thead> className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50"> value={col}
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={`all:${ruleName}`} col="rule" label="rule" /> placeholder="new key"
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={`all:${ruleName}`} col="input_value" label="input_value" /> onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={`all:${ruleName}`} col="count" label="count" className="text-right" /> />
{cols.slice(0, existingCount).map(col => (
<SortHeader sortBy={sortBy} onSort={toggleSort} key={col} ruleName={`all:${ruleName}`} col={col} label={col} />
))}
{extra.map((col, idx) => (
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
<input
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
value={col}
placeholder="new key"
onChange={e => setExtraColName(ruleName, idx, e.target.value)}
/>
</th>
))}
<th className="px-2 py-2">
<button onClick={() => addCol(ruleName)} className="text-gray-300 hover:text-gray-500" title="Add column">+</button>
</th> </th>
<th className="px-3 py-2"></th> ))}
<th className="px-3 py-2"></th> <th className="px-2 py-2">
</tr> <button
</thead> onClick={() => setExtraCols(ec => [...ec, ''])}
<tbody> className="text-gray-300 hover:text-gray-500"
{sortRows(rows, `all:${ruleName}`, (row, col) => title="Add column"
row.is_mapped ? String(row.output?.[col] ?? '') : '' >+</button>
).map(row => { </th>
const k = valueKey(row.extracted_value) <th className="px-3 py-2"></th>
const isSaving = saving[k] <th className="px-3 py-2"></th>
const sampleKey = `all:${ruleName}:${k}` </tr>
const samples = row.sample ? (Array.isArray(row.sample) ? row.sample : [row.sample]) : [] </thead>
<tbody>
{displayRows.map(row => {
const k = valueKey(row.extracted_value)
const isSaving = saving[k]
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
const rowBg = hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
const samples = row.sample
? (Array.isArray(row.sample) ? row.sample : [row.sample])
: []
const cellVal = (col) => { const cellVal = col => {
const drafted = drafts[k]?.[col] const drafted = drafts[k]?.[col]
if (drafted !== undefined) return drafted if (drafted !== undefined) return drafted
return row.is_mapped ? String(row.output?.[col] ?? '') : '' return row.is_mapped ? String(row.output?.[col] ?? '') : ''
} }
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0) return (
const rowBg = hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50' <>
<tr key={k} className={`border-t border-gray-50 hover:bg-gray-50 ${rowBg}`}>
return ( <td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td>
<> <td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
<tr key={k} className={`border-t border-gray-50 hover:bg-gray-50 ${rowBg}`}> {cols.map(col => (
<td className="px-3 py-1.5 text-gray-400">{ruleName}</td> <td key={col} className="px-3 py-1.5">
<td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td> <input
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td> list={`dl-${col}`}
{cols.map(col => ( className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${
<td key={col} className="px-3 py-1.5"> hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'
<input }`}
list={`dl-all-${ruleName}-${col}`} value={cellVal(col)}
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300'}`} onChange={e => setCellValue(row.extracted_value, col, e.target.value)}
value={cellVal(col)} onKeyDown={e => e.key === 'Enter' && saveRow(row)}
onChange={e => setCellValue(row.extracted_value, col, e.target.value)} />
onKeyDown={e => e.key === 'Enter' && saveAllRow(row, cols)}
/>
</td>
))}
<td />
<td className="px-3 py-1.5">
{samples.length > 0 && (
<button
className="text-blue-400 hover:text-blue-600 whitespace-nowrap"
onClick={() => setSampleOpen(s => ({ ...s, [sampleKey]: !s[sampleKey] }))}
>
{sampleOpen[sampleKey] ? 'hide' : 'show'}
</button>
)}
</td> </td>
<td className="px-3 py-1.5"> ))}
<td />
<td className="px-3 py-1.5 whitespace-nowrap">
{samples.length > 0 && (
<button <button
onClick={() => saveAllRow(row, cols)} className="text-blue-400 hover:text-blue-600"
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
>
{sampleOpen[k] ? 'hide' : 'show'}
</button>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<button
onClick={() => saveRow(row)}
disabled={isSaving} disabled={isSaving}
className="bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap" className="bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
> >
{isSaving ? '…' : 'Save'} {isSaving ? '…' : 'Save'}
</button> </button>
</td> {row.is_mapped && (
</tr> <button
{sampleOpen[sampleKey] && (() => { onClick={() => deleteRow(row)}
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))] className="text-red-400 hover:text-red-600 text-base leading-none"
return ( title="Remove mapping"
<tr className="border-t border-gray-50 bg-gray-50"> >×</button>
<td colSpan={3 + cols.length + 3} className="px-3 py-2"> )}
<table className="w-full text-xs border border-gray-100 rounded bg-white"> </div>
<thead> </td>
<tr className="bg-gray-50 border-b border-gray-100"> </tr>
{sampleOpen[k] && (() => {
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
return (
<tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50">
<td colSpan={2 + cols.length + 4} className="px-3 py-2">
<table className="w-full text-xs border border-gray-100 rounded bg-white">
<thead>
<tr className="bg-gray-50 border-b border-gray-100">
{sampleCols.map(c => (
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th>
))}
</tr>
</thead>
<tbody>
{samples.map((rec, i) => (
<tr key={i} className="border-t border-gray-50">
{sampleCols.map(c => ( {sampleCols.map(c => (
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th> <td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
{rec[c] != null ? String(rec[c]) : ''}
</td>
))} ))}
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{samples.map((rec, i) => ( </table>
<tr key={i} className="border-t border-gray-50"> </td>
{sampleCols.map(c => ( </tr>
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">{rec[c] != null ? String(rec[c]) : ''}</td> )
))} })()}
</tr> </>
))} )
</tbody> })}
</table> </tbody>
</td> </table>
</tr> </div>
) )}
})()} </div>
</>
)
})}
</tbody>
</table>
</div>
)
})
})()}
{/* Mapped tab */}
{!loading && tab === 'mapped' && (
<>
{mapped.length === 0
? <p className="text-sm text-gray-400">No mappings yet.</p>
: (
<table className="w-full text-sm bg-white border border-gray-200 rounded">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100">
<th className="px-4 py-2 font-medium">Rule</th>
<th className="px-4 py-2 font-medium">Input</th>
<th className="px-4 py-2 font-medium">Output</th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{mapped.map(m => (
editingId === m.id ? (
<tr key={m.id} className="border-t border-gray-50 bg-blue-50">
<td className="px-4 py-2 text-xs text-gray-400">{m.rule_name}</td>
<td className="px-4 py-2 font-mono text-gray-700">{displayValue(m.input_value)}</td>
<td className="px-4 py-2">
<div className="space-y-1">
{(editDrafts[m.id] || []).map((pair, i) => (
<div key={i} className="flex gap-1">
<input
className="border border-gray-200 rounded px-2 py-1 text-xs w-24 focus:outline-none focus:border-blue-400"
value={pair.key} placeholder="key"
onChange={e => updateEditKey(m.id, i, e.target.value)}
/>
<input
className="border border-gray-200 rounded px-2 py-1 text-xs w-32 focus:outline-none focus:border-blue-400"
value={pair.value} placeholder="value"
onChange={e => updateEditValue(m.id, i, e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveEdit(m)}
/>
</div>
))}
<button className="text-xs text-gray-300 hover:text-gray-500"
onClick={() => addEditPair(m.id)}>+ field</button>
</div>
</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<button onClick={() => saveEdit(m)} disabled={saving[m.id]}
className="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700 disabled:opacity-50"
>{saving[m.id] ? '…' : 'Save'}</button>
<button onClick={() => setEditingId(null)}
className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
</div>
</td>
</tr>
) : (
<tr key={m.id} className="border-t border-gray-50 hover:bg-gray-50">
<td className="px-4 py-2 text-xs text-gray-400">{m.rule_name}</td>
<td className="px-4 py-2 font-mono text-gray-700">{displayValue(m.input_value)}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500">{JSON.stringify(m.output)}</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<button onClick={() => startEdit(m)}
className="text-xs text-blue-400 hover:text-blue-600">Edit</button>
<button onClick={() => deleteMapping(m.id)}
className="text-xs text-red-400 hover:text-red-600">Delete</button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
)
}
</>
)}
</div> </div>
) )
} }