From dc32060c42b8cc10873504310966ead92263eda2 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 15 Apr 2026 20:22:52 -0400 Subject: [PATCH] Add global Remap page for bulk output value replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/routes/mappings.js | 38 ++++++ database/queries/mappings.sql | 38 ++++++ ui/src/App.jsx | 3 + ui/src/api.js | 5 + ui/src/pages/Remap.jsx | 214 ++++++++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 ui/src/pages/Remap.jsx diff --git a/api/routes/mappings.js b/api/routes/mappings.js index ad619be..9bcea92 100644 --- a/api/routes/mappings.js +++ b/api/routes/mappings.js @@ -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 router.get('/source/:source_name/unmapped', async (req, res, next) => { try { diff --git a/database/queries/mappings.sql b/database/queries/mappings.sql index 283925f..8db045b 100644 --- a/database/queries/mappings.sql +++ b/database/queries/mappings.sql @@ -221,3 +221,41 @@ RETURNS TABLE (col TEXT, val TEXT) AS $$ AND e.value <> '' ORDER BY e.key, e.value; $$ 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; diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 716572c..66e34b7 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -9,12 +9,14 @@ import Mappings from './pages/Mappings' import Records from './pages/Records' import Log from './pages/Log' import Pivot from './pages/Pivot' +import Remap from './pages/Remap' const NAV = [ { to: '/sources', label: 'Sources' }, { to: '/import', label: 'Import' }, { to: '/rules', label: 'Rules' }, { to: '/mappings', label: 'Mappings' }, + { to: '/remap', label: 'Remap' }, { to: '/records', label: 'Records' }, { to: '/pivot', label: 'Pivot' }, { to: '/log', label: 'Log' }, @@ -144,6 +146,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api.js b/ui/src/api.js index f8bb359..ac2a106 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -103,6 +103,11 @@ export const api = { updateMapping: (id, body) => request('PUT', `/mappings/${id}`, body), 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 getPivotLayouts: (source) => request('GET', `/sources/${source}/layouts`), savePivotLayout: (source, layout_name, config) => request('POST', `/sources/${source}/layouts`, { layout_name, config }), diff --git a/ui/src/pages/Remap.jsx b/ui/src/pages/Remap.jsx new file mode 100644 index 0000000..599a8e1 --- /dev/null +++ b/ui/src/pages/Remap.jsx @@ -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 ( +
+

Remap Output Values

+ + {/* Search */} +
+ 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" + /> + +
+ + {/* Search results */} + {results !== null && ( +
+ {results.length === 0 ? ( +

No matching output values found.

+ ) : ( + <> +
+ {results.length} result{results.length !== 1 ? 's' : ''} — click one to remap +
+ + + + + + + + + + {results.map((r, i) => { + const isActive = selected?.col === r.col && selected?.val === r.val + return ( + handleSelect(r)} + className={`border-t border-gray-100 cursor-pointer transition-colors + ${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}> + + + + + ) + })} + +
FieldValueMappings
{r.col}{r.val}{r.mapping_count}
+ + )} +
+ )} + + {/* Remap panel */} + {selected && ( +
+
+ Remap {selected.col} +
+
+
+
From
+
+ {selected.val} +
+
+
+
+
To
+ 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" + /> +
+
+ +
+
+ + {msg && ( +
+ {msg.text} +
+ )} + + {/* Affected mappings */} + {loadingMatches ? ( +

Loading…

+ ) : matches && matches.length > 0 && ( +
+
+ Affected mappings +
+ + + + + + + + + + + {matches.map(m => ( + + + + + + + ))} + +
SourceRuleInputOutput
{m.source_name}{m.rule_name} + {typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)} + + {Object.entries(m.output).map(([k, v]) => ( + + {k}: {v}{' '} + + ))} +
+
+ )} +
+ )} +
+ ) +}