dataflow/ui/src/pages/Mappings.jsx
2026-04-25 10:28:32 -04:00

674 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="relative">
<input
ref={inputRef}
className={className}
value={value}
placeholder={placeholder}
onChange={e => { 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 && (
<div
ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto"
>
{filtered.map((s, i) => (
<div
key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${
i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
onMouseDown={e => { e.preventDefault(); select(s) }}
>
{s}
</div>
))}
</div>
)}
</div>
)
}
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 (
<th
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`}
onClick={() => onSort(col)}
>
{label}
<span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
</th>
)
}
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 <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const displayRows = sortedRows(filteredRows)
return (
<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>
)}
{selectedRule && (
<div className="relative">
<input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200'
}`}
placeholder="filter regex…"
value={rowFilter}
onChange={e => setRowFilter(e.target.value)}
/>
{rowFilter && !rowFilterError && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">
{filteredRows.length}
</span>
)}
</div>
)}
{dirtyCount > 0 && (
<button
onClick={saveAllPending}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
>
Save all ({dirtyCount})
</button>
)}
<div className="ml-auto flex items-center gap-2">
{selectedRule && (
<button
onClick={async () => {
try {
const url = api.exportMappingsUrl(source, selectedRule)
const res = await fetch(url, { headers: authHeaders() })
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `mappings_${source}.tsv`
a.click()
URL.revokeObjectURL(a.href)
} catch (err) {
alert(err.message)
}
}}
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600"
>
Export TSV
</button>
)}
<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} />
</label>
</div>
</div>
{/* 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">
{/* Bulk assign bar */}
{selected.size > 0 && (
<div className="flex items-center gap-2 mb-2 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
<span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span>
{cols.map(col => (
<AutocompleteInput
key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
placeholder={col}
value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
suggestions={[...(valuesByCol[col] || [])].sort()}
/>
))}
<button
onClick={applyBulk}
disabled={Object.values(bulkDraft).every(v => !v.trim())}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40 whitespace-nowrap"
>
Apply to {selected.size}
</button>
<button
onClick={() => { setSelected(new Set()); setBulkDraft({}) }}
className="text-xs text-blue-400 hover:text-blue-600"
>
cancel
</button>
</div>
)}
<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">
<th className="px-2 py-2 w-6">
<input
type="checkbox"
className="cursor-pointer"
checked={displayRows.length > 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())
}}
/>
</th>
<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} />
))}
{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-2 py-2">
<button
onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-400 hover:text-gray-700 font-medium"
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 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 (
<>
<tr
key={k}
ref={el => 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}
>
<td className="px-2 py-1.5">
<input
type="checkbox"
className="cursor-pointer"
checked={isSelected}
onChange={() => {
setSelected(s => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n })
setCursorKey(k)
}}
/>
</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">
<AutocompleteInput
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={v => setCellValue(row.extracted_value, col, v)}
onEnter={() => saveRow(row)}
suggestions={[...(valuesByCol[col] || [])].sort()}
/>
</td>
))}
<td />
<td className="px-3 py-1.5 whitespace-nowrap">
{samples.length > 0 && (
<button
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>
{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={3 + 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 => (
<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>
)}
</div>
</div>
)
}