import { useState, useEffect, useRef } from 'react' import { api, authHeaders } from '../api' function AutocompleteInput({ value, onChange, onEnter, 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 const item = listRef.current.children[highlighted] item?.scrollIntoView({ block: 'nearest' }) }, [highlighted, open]) return (
{ onChange(e.target.value); if (!open && e.target.value) openList() }} onKeyDown={handleKeyDown} onBlur={e => { if (!listRef.current?.contains(e.relatedTarget)) setOpen(false) }} /> {open && filtered.length > 0 && dropPos && (
{filtered.map((s, i) => (
{ e.preventDefault(); select(s) }} > {s}
))}
)}
) } function valueKey(v) { return Array.isArray(v) ? JSON.stringify(v) : String(v) } function displayValue(v) { if (Array.isArray(v)) return v.join(' · ') return String(v ?? '') } function SortHeader({ col, label, sortBy, onSort, className = '' }) { const active = sortBy?.col === col return ( onSort(col)} > {label} {active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'} ) } export default function Mappings({ source, onNeedsReprocess }) { const [rules, setRules] = useState([]) const [selectedRule, setSelectedRule] = useState('') const [allValues, setAllValues] = useState([]) const [filter, setFilter] = useState('all') const [drafts, setDrafts] = useState({}) const [extraCols, setExtraCols] = useState([]) const [saving, setSaving] = useState({}) const [sampleOpen, setSampleOpen] = useState({}) const [loading, setLoading] = useState(false) const [importing, setImporting] = useState(false) const [sortBy, setSortBy] = useState(null) const [globalValues, setGlobalValues] = useState({}) const [selected, setSelected] = useState(new Set()) const [bulkDraft, setBulkDraft] = useState({}) const [cursorKey, setCursorKey] = useState(null) const [rowFilter, setRowFilter] = useState('') const rowRefs = useRef({}) useEffect(() => { if (!source) return api.getGlobalValues().then(setGlobalValues).catch(() => {}) api.getRules(source).then(r => setRules(r)).catch(() => {}) }, [source]) useEffect(() => { if (!source || !selectedRule) { setAllValues([]) return } setLoading(true) api.getAllValues(source, selectedRule) .then(a => { setAllValues(a) setDrafts({}) setExtraCols([]) setSelected(new Set()) setBulkDraft({}) setCursorKey(null) setRowFilter('') }) .catch(() => {}) .finally(() => setLoading(false)) }, [source, selectedRule]) // Auto-select all rows matching the regex filter when it changes useEffect(() => { if (!rowFilter) return let re = null try { re = new RegExp(rowFilter, 'i') } catch { return } const tabF = filter === 'unmapped' ? allValues.filter(r => !r.is_mapped) : filter === 'mapped' ? allValues.filter(r => r.is_mapped) : allValues const matches = tabF.filter(r => re.test(displayValue(r.extracted_value))) setSelected(new Set(matches.map(r => valueKey(r.extracted_value)))) }, [rowFilter, filter, allValues]) // Derive output columns and datalist suggestions from mapped rows + global pool 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)) }) }) // Merge global picklist values into suggestions Object.entries(globalValues).forEach(([k, vals]) => { if (!valuesByCol[k]) valuesByCol[k] = new Set() vals.forEach(v => valuesByCol[k].add(v)) }) const cols = [...existingCols, ...extraCols] const unmappedCount = allValues.filter(r => !r.is_mapped).length const mappedCount = allValues.filter(r => r.is_mapped).length const tabFiltered = filter === 'unmapped' ? allValues.filter(r => !r.is_mapped) : filter === 'mapped' ? allValues.filter(r => r.is_mapped) : allValues let rowFilterRe = null let rowFilterError = false if (rowFilter) { try { rowFilterRe = new RegExp(rowFilter, 'i') } catch { rowFilterError = true } } const filteredRows = rowFilterRe ? tabFiltered.filter(r => rowFilterRe.test(displayValue(r.extracted_value))) : tabFiltered function toggleSort(col) { setSortBy(s => { if (s?.col === col) return { col, dir: s.dir === 'asc' ? 'desc' : 'asc' } return { col, dir: 'asc' } }) } function sortedRows(rows) { if (!sortBy) return rows return [...rows].sort((a, b) => { 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 (sortBy.col === 'input_value') { av = displayValue(a.extracted_value) bv = displayValue(b.extracted_value) } else { av = a.is_mapped ? String(a.output?.[sortBy.col] ?? '') : '' bv = b.is_mapped ? String(b.output?.[sortBy.col] ?? '') : '' } return sortBy.dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av) }) } function setCellValue(extractedValue, col, value) { const k = valueKey(extractedValue) const targets = selected.has(k) && selected.size > 1 ? [...selected] : [k] setDrafts(d => { const next = { ...d } for (const sk of targets) next[sk] = { ...(next[sk] || {}), [col]: value } return next }) } async function saveRow(row) { const k = valueKey(row.extracted_value) const output = Object.fromEntries( cols.map(col => { const drafted = drafts[k]?.[col] const val = drafted !== undefined ? drafted : (row.is_mapped ? String(row.output?.[col] ?? '') : '') return [col, val] }).filter(([, v]) => v.trim()) ) if (Object.keys(output).length === 0) return setSaving(s => ({ ...s, [k]: true })) try { if (row.is_mapped && row.mapping_id) { const updated = await api.updateMapping(row.mapping_id, { output }) setAllValues(av => av.map(x => valueKey(x.extracted_value) === k ? { ...x, output: updated.output } : x )) } else { const created = await api.createMapping({ source_name: source, rule_name: row.rule_name, input_value: row.extracted_value, output }) setAllValues(av => av.map(x => valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output } : x )) } onNeedsReprocess?.(source) setDrafts(d => { const n = { ...d }; delete n[k]; return n }) } catch (err) { alert(err.message) } finally { setSaving(s => ({ ...s, [k]: false })) } } 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 => saveRow(row))) setRowFilter('') } async function applyBulk() { const output = Object.fromEntries( Object.entries(bulkDraft).filter(([, v]) => v.trim()) ) if (Object.keys(output).length === 0) return const rows = sortedRows(filteredRows).filter(r => selected.has(valueKey(r.extracted_value))) await Promise.all(rows.map(async row => { const k = valueKey(row.extracted_value) const merged = { ...(row.is_mapped ? row.output : {}), ...output } setSaving(s => ({ ...s, [k]: true })) try { if (row.is_mapped && row.mapping_id) { const updated = await api.updateMapping(row.mapping_id, { output: merged }) setAllValues(av => av.map(x => valueKey(x.extracted_value) === k ? { ...x, output: updated.output } : x)) } else { const created = await api.createMapping({ source_name: source, rule_name: row.rule_name, input_value: row.extracted_value, output: merged }) setAllValues(av => av.map(x => valueKey(x.extracted_value) === k ? { ...x, is_mapped: true, mapping_id: created.id, output: merged } : x)) } } catch (err) { alert(err.message) } finally { setSaving(s => ({ ...s, [k]: false })) } })) onNeedsReprocess?.(source) setSelected(new Set()) setBulkDraft({}) } async function deleteRow(row) { if (!row.mapping_id) return try { await api.deleteMapping(row.mapping_id) onNeedsReprocess?.(source) 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) { const file = e.target.files[0] if (!file) return e.target.value = '' setImporting(true) try { const result = await api.importMappingsCSV(source, file) alert(`Imported ${result.count} mapping${result.count !== 1 ? 's' : ''}.`) const a = await api.getAllValues(source, selectedRule) setAllValues(a) setDrafts({}) setExtraCols([]) } catch (err) { alert(err.message) } finally { setImporting(false) } } if (!source) return
Select a source first.
const displayRows = sortedRows(filteredRows) return (
{/* Sticky control bar */}
{source} {selectedRule && (
{[ { key: 'all', label: `All (${allValues.length})` }, { key: 'unmapped', label: `Unmapped (${unmappedCount})` }, { key: 'mapped', label: `Mapped (${mappedCount})` }, ].map(({ key, label }) => ( ))}
)} {selectedRule && (
setRowFilter(e.target.value)} /> {rowFilter && !rowFilterError && ( {filteredRows.length} )}
)} {dirtyCount > 0 && ( )}
{selectedRule && ( )}
{/* Content */}
{!selectedRule && (

Select a rule to view mappings.

)} {selectedRule && loading && (

Loading…

)} {selectedRule && !loading && allValues.length === 0 && (

No extracted values for this rule. Run a transform first.

)} {selectedRule && !loading && allValues.length > 0 && (
{/* Bulk assign bar */} {selected.size > 0 && (
{selected.size} selected {cols.map(col => ( setBulkDraft(d => ({ ...d, [col]: v }))} suggestions={[...(valuesByCol[col] || [])].sort()} /> ))}
)} {existingCols.map(col => ( ))} {extraCols.map((col, idx) => ( ))} {displayRows.map(row => { const k = valueKey(row.extracted_value) const rowIdx = displayRows.indexOf(row) const isSaving = saving[k] const isSelected = selected.has(k) const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0) const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50' function handleRowClick(e) { if (e.target.closest('input,button,a,select')) return setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n }) setCursorKey(k) } function handleRowKeyDown(e) { if (!e.shiftKey || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return e.preventDefault() const delta = e.key === 'ArrowDown' ? 1 : -1 const curIdx = cursorKey ? displayRows.findIndex(r => valueKey(r.extracted_value) === cursorKey) : rowIdx const nextIdx = Math.max(0, Math.min(displayRows.length - 1, curIdx + delta)) const nextKey = valueKey(displayRows[nextIdx].extracted_value) setSelected(s => new Set([...s, nextKey])) setCursorKey(nextKey) rowRefs.current[nextKey]?.focus() } 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] ?? '') : '' } return ( <> rowRefs.current[k] = el} tabIndex={0} className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`} onClick={handleRowClick} onKeyDown={handleRowKeyDown} > {cols.map(col => ( ))} {sampleOpen[k] && (() => { const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))] return ( ) })()} ) })}
0 && displayRows.every(r => selected.has(valueKey(r.extracted_value)))} onChange={e => { if (e.target.checked) setSelected(new Set(displayRows.map(r => valueKey(r.extracted_value)))) else setSelected(new Set()) }} /> setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })} />
{ setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n }) setCursorKey(k) }} /> {displayValue(row.extracted_value)} {row.record_count} setCellValue(row.extracted_value, col, v)} onEnter={() => saveRow(row)} suggestions={[...(valuesByCol[col] || [])].sort()} /> {samples.length > 0 && ( )}
{row.is_mapped && ( )}
{sampleCols.map(c => ( ))} {samples.map((rec, i) => ( {sampleCols.map(c => ( ))} ))}
{c}
{rec[c] != null ? String(rec[c]) : ''}
)}
) }