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>
This commit is contained in:
parent
bda59c7675
commit
dc32060c42
@ -54,6 +54,44 @@ module.exports = (pool) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search output field values across all mappings (for global remap)
|
||||||
|
router.get('/outputs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { search = '' } = req.query;
|
||||||
|
const result = await pool.query(`SELECT * FROM search_mapping_outputs(${lit(search)})`);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get individual mappings for a specific output field value
|
||||||
|
router.get('/outputs/:col/:val', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM get_mappings_by_output_field(${lit(req.params.col)}, ${lit(req.params.val)})`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remap a field value globally across all mappings
|
||||||
|
router.post('/remap-field', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { col, from_val, to_val } = req.body;
|
||||||
|
if (!col || from_val == null || to_val == null)
|
||||||
|
return res.status(400).json({ error: 'col, from_val, and to_val are required' });
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT remap_output_field(${lit(col)}, ${lit(from_val)}, ${lit(to_val)}) AS updated`
|
||||||
|
);
|
||||||
|
res.json({ updated: result.rows[0].updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get unmapped values
|
// Get unmapped values
|
||||||
router.get('/source/:source_name/unmapped', async (req, res, next) => {
|
router.get('/source/:source_name/unmapped', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -221,3 +221,41 @@ RETURNS TABLE (col TEXT, val TEXT) AS $$
|
|||||||
AND e.value <> ''
|
AND e.value <> ''
|
||||||
ORDER BY e.key, e.value;
|
ORDER BY e.key, e.value;
|
||||||
$$ LANGUAGE sql STABLE;
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
-- ── Remap output field values ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Search for distinct (field, value) pairs across all mapping outputs
|
||||||
|
CREATE OR REPLACE FUNCTION search_mapping_outputs(p_search TEXT)
|
||||||
|
RETURNS TABLE (col TEXT, val TEXT, mapping_count BIGINT) AS $$
|
||||||
|
SELECT e.key AS col, e.value AS val, COUNT(*) AS mapping_count
|
||||||
|
FROM dataflow.mappings m
|
||||||
|
CROSS JOIN LATERAL jsonb_each_text(m.output) AS e(key, value)
|
||||||
|
WHERE e.value ILIKE '%' || p_search || '%'
|
||||||
|
AND e.value IS NOT NULL
|
||||||
|
AND e.value <> ''
|
||||||
|
GROUP BY e.key, e.value
|
||||||
|
ORDER BY e.key, e.value;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
-- Get individual mappings matching a specific output field value
|
||||||
|
CREATE OR REPLACE FUNCTION get_mappings_by_output_field(p_col TEXT, p_val TEXT)
|
||||||
|
RETURNS TABLE (id INT, source_name TEXT, rule_name TEXT, input_value JSONB, output JSONB) AS $$
|
||||||
|
SELECT m.id, m.source_name, m.rule_name, m.input_value, m.output
|
||||||
|
FROM dataflow.mappings m
|
||||||
|
WHERE m.output->>(p_col) = p_val
|
||||||
|
ORDER BY m.source_name, m.rule_name, m.input_value::text;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
-- Replace a specific field value across all matching mappings
|
||||||
|
CREATE OR REPLACE FUNCTION remap_output_field(p_col TEXT, p_from_val TEXT, p_to_val TEXT)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
updated_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE dataflow.mappings
|
||||||
|
SET output = jsonb_set(output, ARRAY[p_col], to_jsonb(p_to_val))
|
||||||
|
WHERE output->>(p_col) = p_from_val;
|
||||||
|
GET DIAGNOSTICS updated_count = ROW_COUNT;
|
||||||
|
RETURN updated_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|||||||
@ -9,12 +9,14 @@ import Mappings from './pages/Mappings'
|
|||||||
import Records from './pages/Records'
|
import Records from './pages/Records'
|
||||||
import Log from './pages/Log'
|
import Log from './pages/Log'
|
||||||
import Pivot from './pages/Pivot'
|
import Pivot from './pages/Pivot'
|
||||||
|
import Remap from './pages/Remap'
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/sources', label: 'Sources' },
|
{ to: '/sources', label: 'Sources' },
|
||||||
{ to: '/import', label: 'Import' },
|
{ to: '/import', label: 'Import' },
|
||||||
{ to: '/rules', label: 'Rules' },
|
{ to: '/rules', label: 'Rules' },
|
||||||
{ to: '/mappings', label: 'Mappings' },
|
{ to: '/mappings', label: 'Mappings' },
|
||||||
|
{ to: '/remap', label: 'Remap' },
|
||||||
{ to: '/records', label: 'Records' },
|
{ to: '/records', label: 'Records' },
|
||||||
{ to: '/pivot', label: 'Pivot' },
|
{ to: '/pivot', label: 'Pivot' },
|
||||||
{ to: '/log', label: 'Log' },
|
{ to: '/log', label: 'Log' },
|
||||||
@ -144,6 +146,7 @@ export default function App() {
|
|||||||
<Route path="/import" element={<Import source={source} />} />
|
<Route path="/import" element={<Import source={source} />} />
|
||||||
<Route path="/rules" element={<Rules source={source} />} />
|
<Route path="/rules" element={<Rules source={source} />} />
|
||||||
<Route path="/mappings" element={<Mappings source={source} />} />
|
<Route path="/mappings" element={<Mappings source={source} />} />
|
||||||
|
<Route path="/remap" element={<Remap />} />
|
||||||
<Route path="/records" element={<Records source={source} />} />
|
<Route path="/records" element={<Records source={source} />} />
|
||||||
<Route path="/pivot" element={<Pivot source={source} />} />
|
<Route path="/pivot" element={<Pivot source={source} />} />
|
||||||
<Route path="/log" element={<Log />} />
|
<Route path="/log" element={<Log />} />
|
||||||
|
|||||||
@ -103,6 +103,11 @@ export const api = {
|
|||||||
updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body),
|
updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body),
|
||||||
deleteMapping: (id) => request('DELETE', `/mappings/${id}`),
|
deleteMapping: (id) => request('DELETE', `/mappings/${id}`),
|
||||||
|
|
||||||
|
// Global remap
|
||||||
|
searchMappingOutputs: (search) => request('GET', `/mappings/outputs?search=${encodeURIComponent(search)}`),
|
||||||
|
getMappingsByOutputField: (col, val) => request('GET', `/mappings/outputs/${encodeURIComponent(col)}/${encodeURIComponent(val)}`),
|
||||||
|
remapOutputField: (col, from_val, to_val) => request('POST', '/mappings/remap-field', { col, from_val, to_val }),
|
||||||
|
|
||||||
// Pivot layouts
|
// Pivot layouts
|
||||||
getPivotLayouts: (source) => request('GET', `/sources/${source}/layouts`),
|
getPivotLayouts: (source) => request('GET', `/sources/${source}/layouts`),
|
||||||
savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }),
|
savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }),
|
||||||
|
|||||||
214
ui/src/pages/Remap.jsx
Normal file
214
ui/src/pages/Remap.jsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user