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:
parent
dcac6def87
commit
63b1634b40
@ -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),
|
|
||||||
api.getMappings(source, rule),
|
|
||||||
api.getAllValues(source, rule)
|
|
||||||
]).then(([u, m, a]) => {
|
|
||||||
setUnmapped(u)
|
|
||||||
setMapped(m)
|
|
||||||
setAllValues(a)
|
setAllValues(a)
|
||||||
setDrafts({})
|
setDrafts({})
|
||||||
setExtraCols({})
|
setExtraCols([])
|
||||||
}).catch(() => {}).finally(() => setLoading(false))
|
})
|
||||||
|
.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,69 +194,53 @@ 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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Save all ({dirtyCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{selectedRule && (
|
||||||
<a
|
<a
|
||||||
href={api.exportMappingsUrl(source, selectedRule)}
|
href={api.exportMappingsUrl(source, selectedRule)}
|
||||||
download
|
download
|
||||||
@ -310,6 +248,7 @@ export default function Mappings({ source }) {
|
|||||||
>
|
>
|
||||||
Export TSV
|
Export TSV
|
||||||
</a>
|
</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,47 +256,22 @@ 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)}
|
|
||||||
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 => (
|
{cols.map(col => (
|
||||||
<datalist key={col} id={`dl-${ruleName}-${col}`}>
|
<datalist key={col} id={`dl-${col}`}>
|
||||||
{[...(valuesByRuleCol[ruleName]?.[col] || [])].sort().map(v => (
|
{[...(valuesByCol[col] || [])].sort().map(v => (
|
||||||
<option key={v} value={v} />
|
<option key={v} value={v} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
@ -365,257 +279,101 @@ export default function Mappings({ source }) {
|
|||||||
<table className="w-full text-xs bg-white border border-gray-200 rounded">
|
<table className="w-full text-xs bg-white border border-gray-200 rounded">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50">
|
<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 col="input_value" label="input_value" sortBy={sortBy} onSort={toggleSort} />
|
||||||
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={ruleName} col="input_value" label="input_value" />
|
<SortHeader col="count" label="count" sortBy={sortBy} onSort={toggleSort} className="text-right" />
|
||||||
<SortHeader sortBy={sortBy} onSort={toggleSort} ruleName={ruleName} col="count" label="count" className="text-right" />
|
{existingCols.map(col => (
|
||||||
{cols.slice(0, existingCount).map(col => (
|
<SortHeader key={col} col={col} label={col} sortBy={sortBy} onSort={toggleSort} />
|
||||||
<SortHeader sortBy={sortBy} onSort={toggleSort} key={col} ruleName={ruleName} col={col} label={col} />
|
|
||||||
))}
|
))}
|
||||||
{extra.map((col, idx) => (
|
{extraCols.map((col, idx) => (
|
||||||
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
|
<th key={`extra-${idx}`} className="px-3 py-2 font-medium">
|
||||||
<input
|
<input
|
||||||
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
|
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal"
|
||||||
value={col}
|
value={col}
|
||||||
placeholder="new key"
|
placeholder="new key"
|
||||||
onChange={e => setExtraColName(ruleName, idx, e.target.value)}
|
onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-2 py-2">
|
<th className="px-2 py-2">
|
||||||
<button onClick={() => addCol(ruleName)} className="text-gray-300 hover:text-gray-500 font-normal" title="Add column">+</button>
|
<button
|
||||||
|
onClick={() => setExtraCols(ec => [...ec, ''])}
|
||||||
|
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-3 py-2"></th>
|
<th className="px-3 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortRows(rows, ruleName, (row, col) => getCellValue(row.extracted_value, col)).map(row => {
|
{displayRows.map(row => {
|
||||||
const k = valueKey(row.extracted_value)
|
const k = valueKey(row.extracted_value)
|
||||||
const isSaving = saving[k]
|
const isSaving = saving[k]
|
||||||
const sampleKey = `${ruleName}:${k}`
|
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
|
||||||
const samples = row.sample ? (Array.isArray(row.sample) ? row.sample : [row.sample]) : []
|
const rowBg = hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
|
||||||
|
const samples = row.sample
|
||||||
|
? (Array.isArray(row.sample) ? row.sample : [row.sample])
|
||||||
|
: []
|
||||||
|
|
||||||
return (
|
const cellVal = col => {
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
<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>
|
|
||||||
</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]) : []
|
|
||||||
|
|
||||||
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)
|
|
||||||
const rowBg = hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr key={k} className={`border-t border-gray-50 hover:bg-gray-50 ${rowBg}`}>
|
<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 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>
|
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td>
|
||||||
{cols.map(col => (
|
{cols.map(col => (
|
||||||
<td key={col} className="px-3 py-1.5">
|
<td key={col} className="px-3 py-1.5">
|
||||||
<input
|
<input
|
||||||
list={`dl-all-${ruleName}-${col}`}
|
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'}`}
|
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)}
|
value={cellVal(col)}
|
||||||
onChange={e => setCellValue(row.extracted_value, col, e.target.value)}
|
onChange={e => setCellValue(row.extracted_value, col, e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && saveAllRow(row, cols)}
|
onKeyDown={e => e.key === 'Enter' && saveRow(row)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td />
|
<td />
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||||
{samples.length > 0 && (
|
{samples.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="text-blue-400 hover:text-blue-600 whitespace-nowrap"
|
className="text-blue-400 hover:text-blue-600"
|
||||||
onClick={() => setSampleOpen(s => ({ ...s, [sampleKey]: !s[sampleKey] }))}
|
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
|
||||||
>
|
>
|
||||||
{sampleOpen[sampleKey] ? 'hide' : 'show'}
|
{sampleOpen[k] ? 'hide' : 'show'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => saveAllRow(row, cols)}
|
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>
|
||||||
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{sampleOpen[sampleKey] && (() => {
|
{sampleOpen[k] && (() => {
|
||||||
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
|
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-gray-50 bg-gray-50">
|
<tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50">
|
||||||
<td colSpan={3 + cols.length + 3} className="px-3 py-2">
|
<td colSpan={2 + cols.length + 4} className="px-3 py-2">
|
||||||
<table className="w-full text-xs border border-gray-100 rounded bg-white">
|
<table className="w-full text-xs border border-gray-100 rounded bg-white">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 border-b border-gray-100">
|
<tr className="bg-gray-50 border-b border-gray-100">
|
||||||
@ -628,7 +386,9 @@ export default function Mappings({ source }) {
|
|||||||
{samples.map((rec, i) => (
|
{samples.map((rec, i) => (
|
||||||
<tr key={i} className="border-t border-gray-50">
|
<tr key={i} className="border-t border-gray-50">
|
||||||
{sampleCols.map(c => (
|
{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>
|
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap">
|
||||||
|
{rec[c] != null ? String(rec[c]) : ''}
|
||||||
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -644,84 +404,8 @@ export default function Mappings({ source }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user