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 ?? '')
}
function SortHeader({ ruleName, col, label, sortBy, onSort, className = '' }) {
const s = sortBy[ruleName]
const active = s?.col === col
function SortHeader({ col, label, sortBy, onSort, className = '' }) {
const active = sortBy?.col === col
return (
<th
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
onClick={() => onSort(ruleName, col)}
onClick={() => onSort(col)}
>
{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>
)
}
export default function Mappings({ source }) {
const [tab, setTab] = useState('unmapped')
const [rules, setRules] = useState([])
const [selectedRule, setSelectedRule] = useState('')
const [unmapped, setUnmapped] = useState([])
const [mapped, setMapped] = useState([])
// drafts[valueKey][colKey] = value
const [allValues, setAllValues] = useState([])
const [filter, setFilter] = useState('all')
const [drafts, setDrafts] = useState({})
// extraCols[ruleName] = [colName, ...] user-added columns
const [extraCols, setExtraCols] = useState({})
const [extraCols, setExtraCols] = useState([])
const [saving, setSaving] = useState({})
const [sampleOpen, setSampleOpen] = useState({})
const [loading, setLoading] = useState(false)
const [editingId, setEditingId] = useState(null)
const [editDrafts, setEditDrafts] = useState({})
const [importing, setImporting] = useState(false)
// sortBy[ruleName] = { col, dir: 'asc'|'desc' }
const [sortBy, setSortBy] = useState({})
const [allValues, setAllValues] = useState([])
const [sortBy, setSortBy] = useState(null)
useEffect(() => {
if (!source) return
api.getRules(source).then(r => {
setRules(r)
if (r.length > 0 && !selectedRule) setSelectedRule(r[0].name)
}).catch(() => {})
api.getRules(source).then(r => setRules(r)).catch(() => {})
}, [source])
useEffect(() => {
if (!source) return
if (!source || !selectedRule) {
setAllValues([])
return
}
setLoading(true)
const rule = selectedRule || undefined
Promise.all([
api.getUnmapped(source, rule),
api.getMappings(source, rule),
api.getAllValues(source, rule)
]).then(([u, m, a]) => {
setUnmapped(u)
setMapped(m)
setAllValues(a)
setDrafts({})
setExtraCols({})
}).catch(() => {}).finally(() => setLoading(false))
api.getAllValues(source, selectedRule)
.then(a => {
setAllValues(a)
setDrafts({})
setExtraCols([])
})
.catch(() => {})
.finally(() => setLoading(false))
}, [source, selectedRule])
// Derive existing output key columns from mapped values, per rule
const existingColsByRule = {}
// Distinct values already used per rule+column (for datalist suggestions)
const valuesByRuleCol = {}
mapped.forEach(m => {
if (!existingColsByRule[m.rule_name]) existingColsByRule[m.rule_name] = []
Object.entries(m.output || {}).forEach(([k, v]) => {
if (!existingColsByRule[m.rule_name].includes(k))
existingColsByRule[m.rule_name].push(k)
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))
// Derive output columns and datalist suggestions from mapped rows
const existingCols = []
const valuesByCol = {}
allValues.forEach(row => {
if (!row.is_mapped) return
Object.entries(row.output || {}).forEach(([k, v]) => {
if (!existingCols.includes(k)) existingCols.push(k)
if (!valuesByCol[k]) valuesByCol[k] = new Set()
valuesByCol[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 => {
const cur = s[ruleName]
if (cur?.col === col) return { ...s, [ruleName]: { col, dir: cur.dir === 'asc' ? 'desc' : 'asc' } }
return { ...s, [ruleName]: { col, dir: 'asc' } }
if (s?.col === col) return { col, dir: s.dir === 'asc' ? 'desc' : 'asc' }
return { col, dir: 'asc' }
})
}
function sortRows(rows, ruleName, getCellFn) {
const s = sortBy[ruleName]
if (!s) return rows
function sortedRows(rows) {
if (!sortBy) return rows
return [...rows].sort((a, b) => {
if (s.col === 'count') {
const av = a.record_count ?? 0
const bv = b.record_count ?? 0
return s.dir === 'asc' ? av - bv : bv - av
if (sortBy.col === 'count') {
const av = Number(a.record_count) || 0
const bv = Number(b.record_count) || 0
return sortBy.dir === 'asc' ? av - bv : bv - av
}
let av, bv
if (s.col === 'input_value') {
if (sortBy.col === 'input_value') {
av = displayValue(a.extracted_value)
bv = displayValue(b.extracted_value)
} else {
av = String(getCellFn(a, s.col) ?? '')
bv = String(getCellFn(b, s.col) ?? '')
av = a.is_mapped ? String(a.output?.[sortBy.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) {
const k = valueKey(extractedValue)
setDrafts(d => ({ ...d, [k]: { ...(d[k] || {}), [col]: value } }))
}
function addCol(ruleName) {
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) {
async function saveRow(row) {
const k = valueKey(row.extracted_value)
const output = Object.fromEntries(
cols.map(col => {
@ -184,9 +127,7 @@ export default function Mappings({ source }) {
if (row.is_mapped && row.mapping_id) {
const updated = await api.updateMapping(row.mapping_id, { output })
setAllValues(av => av.map(x =>
x.rule_name === row.rule_name && valueKey(x.extracted_value) === k
? { ...x, output: updated.output }
: x
valueKey(x.extracted_value) === k ? { ...x, output: updated.output } : x
))
} else {
await api.createMapping({
@ -196,11 +137,8 @@ export default function Mappings({ source }) {
output
})
setAllValues(av => av.map(x =>
x.rule_name === row.rule_name && valueKey(x.extracted_value) === k
? { ...x, is_mapped: true, output }
: x
valueKey(x.extracted_value) === k ? { ...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 })
} catch (err) {
@ -210,12 +148,31 @@ export default function Mappings({ source }) {
}
}
async function saveAllDrafts(rows, cols) {
const dirty = rows.filter(row => {
const dirtyCount = filteredRows.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)
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) {
@ -226,13 +183,10 @@ export default function Mappings({ source }) {
try {
const result = await api.importMappingsCSV(source, file)
alert(`Imported ${result.count} mapping${result.count !== 1 ? 's' : ''}.`)
const rule = selectedRule || undefined
const [u, m, a] = await Promise.all([api.getUnmapped(source, rule), api.getMappings(source, rule), api.getAllValues(source, rule)])
setUnmapped(u)
setMapped(m)
const a = await api.getAllValues(source, selectedRule)
setAllValues(a)
setDrafts({})
setExtraCols({})
setExtraCols([])
} catch (err) {
alert(err.message)
} 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>
const displayRows = sortedRows(filteredRows)
return (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-gray-800">Mappings {source}</h1>
<div className="flex items-center gap-2">
<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"
<div>
{/* Sticky control bar */}
<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">
<span className="text-sm font-medium text-gray-700">{source}</span>
<select
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
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
</a>
Save all ({dirtyCount})
</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' : ''}`}>
{importing ? 'Importing…' : 'Import TSV'}
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
@ -317,411 +256,156 @@ export default function Mappings({ source }) {
</div>
</div>
{/* Rule filter + tabs */}
<div className="flex items-center gap-3 mb-4">
<select
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400"
value={selectedRule}
onChange={e => setSelectedRule(e.target.value)}
>
<option value="">All rules</option>
{rules.map(r => <option key={r.name} value={r.name}>{r.name}</option>)}
</select>
<div className="flex bg-gray-100 rounded p-0.5">
{['unmapped', 'mapped', 'all'].map(t => (
<button key={t} onClick={() => setTab(t)}
className={`text-sm px-3 py-1 rounded transition-colors ${
tab === t ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500'
}`}>
{t === 'unmapped' ? `Unmapped${unmapped.length ? ` (${unmapped.length})` : ''}` : t === 'mapped' ? 'Mapped' : 'All'}
</button>
))}
</div>
</div>
{loading && <p className="text-sm text-gray-400">Loading</p>}
{/* Unmapped tab — spreadsheet layout */}
{!loading && tab === 'unmapped' && (
<>
{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} />
{/* Content */}
<div className="p-6">
{!selectedRule && (
<p className="text-sm text-gray-400">Select a rule to view mappings.</p>
)}
{selectedRule && loading && (
<p className="text-sm text-gray-400">Loading</p>
)}
{selectedRule && !loading && allValues.length === 0 && (
<p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p>
)}
{selectedRule && !loading && allValues.length > 0 && (
<div className="overflow-x-auto">
{cols.map(col => (
<datalist key={col} id={`dl-${col}`}>
{[...(valuesByCol[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 col="input_value" label="input_value" sortBy={sortBy} onSort={toggleSort} />
<SortHeader col="count" label="count" sortBy={sortBy} onSort={toggleSort} className="text-right" />
{existingCols.map(col => (
<SortHeader key={col} col={col} label={col} sortBy={sortBy} onSort={toggleSort} />
))}
</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={`all:${ruleName}`} col="rule" label="rule" />
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={`all:${ruleName}`} col="input_value" label="input_value" />
<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>
{extraCols.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 => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
/>
</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{sortRows(rows, `all:${ruleName}`, (row, col) =>
row.is_mapped ? String(row.output?.[col] ?? '') : ''
).map(row => {
const k = valueKey(row.extracted_value)
const isSaving = saving[k]
const sampleKey = `all:${ruleName}:${k}`
const samples = row.sample ? (Array.isArray(row.sample) ? row.sample : [row.sample]) : []
))}
<th className="px-2 py-2">
<button
onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-300 hover:text-gray-500"
title="Add column"
>+</button>
</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</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 drafted = drafts[k]?.[col]
if (drafted !== undefined) return drafted
return row.is_mapped ? String(row.output?.[col] ?? '') : ''
}
const cellVal = col => {
const drafted = drafts[k]?.[col]
if (drafted !== undefined) return drafted
return row.is_mapped ? String(row.output?.[col] ?? '') : ''
}
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
const rowBg = hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
return (
<>
<tr key={k} className={`border-t border-gray-50 hover:bg-gray-50 ${rowBg}`}>
<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-all-${ruleName}-${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'}`}
value={cellVal(col)}
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>
)}
return (
<>
<tr key={k} className={`border-t border-gray-50 hover:bg-gray-50 ${rowBg}`}>
<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-${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'
}`}
value={cellVal(col)}
onChange={e => setCellValue(row.extracted_value, col, e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveRow(row)}
/>
</td>
<td className="px-3 py-1.5">
))}
<td />
<td className="px-3 py-1.5 whitespace-nowrap">
{samples.length > 0 && (
<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}
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={3 + 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">
{row.is_mapped && (
<button
onClick={() => deleteRow(row)}
className="text-red-400 hover:text-red-600 text-base leading-none"
title="Remove mapping"
>×</button>
)}
</div>
</td>
</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 => (
<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>
</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>
)
})
})()}
{/* 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>
)
}
</>
)}
))}
</tbody>
</table>
</td>
</tr>
)
})()}
</>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}