dataflow/ui/src/pages/Remap.jsx
Paul Trowbridge dc32060c42 Add global Remap page for bulk output value replacement
- SQL: search_mapping_outputs(search) — distinct (col, val, count) groups
         get_mappings_by_output_field(col, val) — individual mappings
         remap_output_field(col, from, to) — bulk UPDATE via jsonb_set
- API: GET /mappings/outputs?search=, GET /mappings/outputs/:col/:val,
       POST /mappings/remap-field
- UI: Remap page — search output values, click to select, edit the
  replacement value, see all affected mappings, apply globally
- Nav: Remap added between Mappings and Records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:22:52 -04:00

215 lines
8.3 KiB
JavaScript

import { useState, useRef } from 'react'
import { api } from '../api'
export default function Remap() {
const [search, setSearch] = useState('')
const [results, setResults] = useState(null)
const [searching, setSearching] = useState(false)
const [selected, setSelected] = useState(null) // { col, val }
const [matches, setMatches] = useState(null) // individual mappings
const [loadingMatches, setLoadingMatches] = useState(false)
const [toVal, setToVal] = useState('')
const [applying, setApplying] = useState(false)
const [msg, setMsg] = useState(null) // { text, ok }
const searchRef = useRef()
async function handleSearch(e) {
e.preventDefault()
const q = search.trim()
if (!q) return
setSearching(true)
setResults(null)
setSelected(null)
setMatches(null)
setMsg(null)
try {
const rows = await api.searchMappingOutputs(q)
setResults(rows)
} catch (err) {
setMsg({ text: err.message, ok: false })
} finally {
setSearching(false)
}
}
async function handleSelect(row) {
setSelected(row)
setToVal(row.val)
setMatches(null)
setMsg(null)
setLoadingMatches(true)
try {
const rows = await api.getMappingsByOutputField(row.col, row.val)
setMatches(rows)
} catch (err) {
setMsg({ text: err.message, ok: false })
} finally {
setLoadingMatches(false)
}
}
async function handleApply() {
if (!selected || !toVal.trim() || toVal === selected.val) return
setApplying(true)
setMsg(null)
try {
const { updated } = await api.remapOutputField(selected.col, selected.val, toVal.trim())
setMsg({ text: `Updated ${updated} mapping${updated !== 1 ? 's' : ''}.`, ok: true })
// Refresh match list to show new values
const rows = await api.getMappingsByOutputField(selected.col, toVal.trim())
setMatches(rows)
setSelected({ ...selected, val: toVal.trim() })
// Re-run search to refresh counts
const refreshed = await api.searchMappingOutputs(search.trim())
setResults(refreshed)
} catch (err) {
setMsg({ text: err.message, ok: false })
} finally {
setApplying(false)
}
}
return (
<div className="p-6 max-w-4xl">
<h1 className="text-base font-semibold text-gray-800 mb-4">Remap Output Values</h1>
{/* Search */}
<form onSubmit={handleSearch} className="flex items-center gap-2 mb-5">
<input
ref={searchRef}
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search output values…"
className="text-sm border border-gray-300 rounded px-3 py-1.5 w-72 focus:outline-none focus:border-blue-400"
/>
<button type="submit" disabled={searching}
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-50">
{searching ? 'Searching…' : 'Search'}
</button>
</form>
{/* Search results */}
{results !== null && (
<div className="mb-6">
{results.length === 0 ? (
<p className="text-sm text-gray-400">No matching output values found.</p>
) : (
<>
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
{results.length} result{results.length !== 1 ? 's' : ''} click one to remap
</div>
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden">
<thead>
<tr className="bg-gray-50 text-left text-xs text-gray-400 uppercase tracking-wide">
<th className="px-3 py-2">Field</th>
<th className="px-3 py-2">Value</th>
<th className="px-3 py-2 text-right">Mappings</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => {
const isActive = selected?.col === r.col && selected?.val === r.val
return (
<tr key={i}
onClick={() => handleSelect(r)}
className={`border-t border-gray-100 cursor-pointer transition-colors
${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}>
<td className="px-3 py-2 font-mono text-gray-500">{r.col}</td>
<td className="px-3 py-2 font-mono text-gray-800">{r.val}</td>
<td className="px-3 py-2 text-right text-gray-400">{r.mapping_count}</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
</div>
)}
{/* Remap panel */}
{selected && (
<div className="border border-gray-200 rounded p-4 mb-6 bg-white">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3">
Remap <span className="font-mono text-gray-600">{selected.col}</span>
</div>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1">
<div className="text-xs text-gray-400 mb-1">From</div>
<div className="text-sm font-mono bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-gray-700">
{selected.val}
</div>
</div>
<div className="text-gray-300 mt-4"></div>
<div className="flex-1">
<div className="text-xs text-gray-400 mb-1">To</div>
<input
value={toVal}
onChange={e => setToVal(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleApply()}
className="w-full text-sm font-mono border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:border-blue-400"
/>
</div>
<div className="mt-4">
<button
onClick={handleApply}
disabled={applying || !toVal.trim() || toVal.trim() === selected.val}
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-40 whitespace-nowrap">
{applying ? 'Applying…' : `Apply to all ${matches?.length ?? '…'}`}
</button>
</div>
</div>
{msg && (
<div className={`text-sm mb-3 ${msg.ok ? 'text-green-600' : 'text-red-500'}`}>
{msg.text}
</div>
)}
{/* Affected mappings */}
{loadingMatches ? (
<p className="text-xs text-gray-400">Loading</p>
) : matches && matches.length > 0 && (
<div>
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">
Affected mappings
</div>
<table className="w-full text-xs border border-gray-100 rounded overflow-hidden">
<thead>
<tr className="bg-gray-50 text-left text-gray-400">
<th className="px-2 py-1">Source</th>
<th className="px-2 py-1">Rule</th>
<th className="px-2 py-1">Input</th>
<th className="px-2 py-1">Output</th>
</tr>
</thead>
<tbody>
{matches.map(m => (
<tr key={m.id} className="border-t border-gray-50">
<td className="px-2 py-1 font-mono text-gray-500">{m.source_name}</td>
<td className="px-2 py-1 font-mono text-gray-500">{m.rule_name}</td>
<td className="px-2 py-1 font-mono text-gray-700">
{typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)}
</td>
<td className="px-2 py-1 font-mono text-gray-700">
{Object.entries(m.output).map(([k, v]) => (
<span key={k} className={k === selected.col ? 'text-blue-600 font-semibold' : ''}>
{k}: {v}{' '}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}