From 63b1634b404f067f55094952f1cfb384ec3bc2de Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sun, 5 Apr 2026 11:14:24 -0400 Subject: [PATCH] Redesign mappings page: single grid, sticky controls, rule-gated loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/src/pages/Mappings.jsx | 850 ++++++++++++-------------------------- 1 file changed, 267 insertions(+), 583 deletions(-) diff --git a/ui/src/pages/Mappings.jsx b/ui/src/pages/Mappings.jsx index 1b246c8..c970bbf 100644 --- a/ui/src/pages/Mappings.jsx +++ b/ui/src/pages/Mappings.jsx @@ -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 ( onSort(ruleName, col)} + onClick={() => onSort(col)} > {label} - {active ? (s.dir === 'asc' ? '↑' : '↓') : '↕'} + {active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'} ) } 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
Select a source first.
+ const displayRows = sortedRows(filteredRows) + return ( -
-
-

Mappings — {source}

-
- + {/* Sticky control bar */} + - {/* Rule filter + tabs */} -
- - -
- {['unmapped', 'mapped', 'all'].map(t => ( - - ))} -
-
- - {loading &&

Loading…

} - - {/* Unmapped tab — spreadsheet layout */} - {!loading && tab === 'unmapped' && ( - <> - {unmapped.length === 0 - ?

No unmapped values. Run a transform first, or all values are mapped.

- : 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 ( -
- {/* Datalists for column value suggestions */} - {cols.map(col => ( - - {[...(valuesByRuleCol[ruleName]?.[col] || [])].sort().map(v => ( - - ))} - - - - - - - {cols.slice(0, existingCount).map(col => ( - - ))} - {extra.map((col, idx) => ( - - ))} - - - - - - - {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 ( - <> - - - - - {cols.map(col => ( - - ))} - {/* Empty cell under the + button */} - - - - {sampleOpen[sampleKey] && (() => { - const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))] - return ( - - - - ) - })()} - - ) - })} - -
- setExtraColName(ruleName, idx, e.target.value)} - /> - - -
{ruleName}{displayValue(row.extracted_value)}{row.record_count} - setCellValue(row.extracted_value, col, e.target.value)} - onKeyDown={e => e.key === 'Enter' && saveMapping(row, cols)} - /> - - - {samples.length > 0 && ( - - )} - - -
- - - - {sampleCols.map(c => ( - - ))} - - - - {samples.map((rec, i) => ( - - {sampleCols.map(c => ( - - ))} - - ))} - -
{c}
{rec[c] != null ? String(rec[c]) : ''}
-
-
- ) - }) - } - - )} - - {/* 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

No extracted values. Run a transform first.

- - 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 ( -
- {dirtyCount > 0 && ( -
- -
- )} - {cols.map(col => ( - - {[...(allValuesByRuleCol[ruleName]?.[col] || [])].sort().map(v => ( -
) }