diff --git a/api/routes/records.js b/api/routes/records.js index 63eb63e..b80399e 100644 --- a/api/routes/records.js +++ b/api/routes/records.js @@ -49,6 +49,40 @@ module.exports = (pool) => { } }); + // Set overrides for a record and immediately merge into transformed + router.put('/:id/overrides', async (req, res, next) => { + try { + const { overrides } = req.body; + if (!overrides || typeof overrides !== 'object') + return res.status(400).json({ error: 'overrides object required' }); + const result = await pool.query( + `SELECT * FROM set_record_overrides(${lit(parseInt(req.params.id))}, ${lit(overrides)})` + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'Record not found' }); + res.json(result.rows[0]); + } catch (err) { + next(err); + } + }); + + // Clear overrides and reprocess that record to restore computed values + router.delete('/:id/overrides', async (req, res, next) => { + try { + const rec = await pool.query( + `SELECT * FROM clear_record_overrides(${lit(parseInt(req.params.id))})` + ); + if (rec.rows.length === 0) return res.status(404).json({ error: 'Record not found' }); + // Reprocess this record so transformed reflects rules/mappings without overrides + await pool.query( + `SELECT apply_transformations(${lit(rec.rows[0].source_name)}, ARRAY[${lit(parseInt(req.params.id))}::int], true)` + ); + const updated = await pool.query(`SELECT * FROM get_record(${lit(parseInt(req.params.id))})`); + res.json(updated.rows[0]); + } catch (err) { + next(err); + } + }); + // Delete record router.delete('/:id', async (req, res, next) => { try { diff --git a/database/functions.sql b/database/functions.sql index 3eace82..3301f7a 100644 --- a/database/functions.sql +++ b/database/functions.sql @@ -284,7 +284,7 @@ record_additions AS ( -- Update all qualifying records; records with no rule matches get transformed = data updated AS ( UPDATE dataflow.records rec - SET transformed = rec.data || COALESCE(ra.additions, '{}'::jsonb), + SET transformed = rec.data || COALESCE(ra.additions, '{}'::jsonb) || COALESCE(rec.overrides, '{}'::jsonb), transformed_at = CURRENT_TIMESTAMP FROM qualifying q LEFT JOIN record_additions ra ON ra.id = q.id diff --git a/database/queries/records.sql b/database/queries/records.sql index ea9b2af..f48e26b 100644 --- a/database/queries/records.sql +++ b/database/queries/records.sql @@ -39,6 +39,27 @@ RETURNS SETOF dataflow.records AS $$ LIMIT p_limit; $$ LANGUAGE sql STABLE; +-- ── Overrides ───────────────────────────────────────────────────────────────── + +-- Store manual overrides and immediately merge into transformed +CREATE OR REPLACE FUNCTION set_record_overrides(p_id INT, p_overrides JSONB) +RETURNS dataflow.records AS $$ + UPDATE dataflow.records + SET overrides = CASE WHEN p_overrides = '{}'::jsonb THEN NULL ELSE p_overrides END, + transformed = COALESCE(transformed, data) || COALESCE(p_overrides, '{}'::jsonb) + WHERE id = p_id + RETURNING *; +$$ LANGUAGE sql; + +-- Clear overrides; caller should reprocess to restore computed transformed value +CREATE OR REPLACE FUNCTION clear_record_overrides(p_id INT) +RETURNS dataflow.records AS $$ + UPDATE dataflow.records + SET overrides = NULL + WHERE id = p_id + RETURNING *; +$$ LANGUAGE sql; + -- ── Delete ──────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION delete_record(p_id BIGINT) diff --git a/database/queries/sources.sql b/database/queries/sources.sql index 7b2cefd..0c8cc32 100644 --- a/database/queries/sources.sql +++ b/database/queries/sources.sql @@ -188,7 +188,7 @@ BEGIN v_view := 'dfv.' || quote_ident(p_source_name); EXECUTE format('DROP VIEW IF EXISTS %s', v_view); v_sql := format( - 'CREATE VIEW %s AS SELECT %s FROM dataflow.records WHERE source_name = %L AND transformed IS NOT NULL', + 'CREATE VIEW %s AS SELECT id, overrides IS NOT NULL AS _overridden, %s FROM dataflow.records WHERE source_name = %L AND transformed IS NOT NULL', v_view, v_cols, p_source_name ); EXECUTE v_sql; diff --git a/ui/src/api.js b/ui/src/api.js index ac2a106..a9c5a50 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -116,4 +116,7 @@ export const api = { // Records getRecords: (source, limit = 100, offset = 0) => request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), + getRecord: (id) => request('GET', `/records/${id}`), + setRecordOverrides: (id, overrides) => request('PUT', `/records/${id}/overrides`, { overrides }), + clearRecordOverrides: (id) => request('DELETE', `/records/${id}/overrides`), } diff --git a/ui/src/pages/Records.jsx b/ui/src/pages/Records.jsx index 8f1476e..d8a96fa 100644 --- a/ui/src/pages/Records.jsx +++ b/ui/src/pages/Records.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { api } from '../api' const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/ +const HIDDEN_COLS = new Set(['id', '_overridden']) function formatVal(val) { if (val === null || val === undefined) return null @@ -30,12 +31,20 @@ export default function Records({ source }) { const debounceRef = useRef(null) const LIMIT = 100 + // Override panel + const [selectedRecord, setSelectedRecord] = useState(null) // full record from API + const [overrideDraft, setOverrideDraft] = useState({}) // { field: newValue } + const [panelLoading, setPanelLoading] = useState(false) + const [panelSaving, setPanelSaving] = useState(false) + const [panelMsg, setPanelMsg] = useState(null) + useEffect(() => { if (!source) return setOffset(0) setSort({ col: null, dir: 'asc' }) setFilters([]) setViewError(null) + setSelectedRecord(null) load(0, null, 'asc', []) }, [source]) @@ -46,8 +55,7 @@ export default function Records({ source }) { const res = await api.getViewData(source, LIMIT, off, col, dir, active) setExists(res.exists) setRows(res.rows) - if (res.rows.length > 0 && cols.length === 0) setCols(Object.keys(res.rows[0])) - else if (res.rows.length > 0) setCols(Object.keys(res.rows[0])) + if (res.rows.length > 0) setCols(Object.keys(res.rows[0])) } catch (err) { setViewError(err.message) } finally { @@ -70,7 +78,8 @@ export default function Records({ source }) { } function addFilter() { - setFilters(f => [...f, { col: cols[0] || '', pattern: '' }]) + const visCols = cols.filter(c => !HIDDEN_COLS.has(c)) + setFilters(f => [...f, { col: visCols[0] || '', pattern: '' }]) } function removeFilter(i) { @@ -90,131 +99,278 @@ export default function Records({ source }) { function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o, sort.col, sort.dir, filters) } function next() { const o = offset + LIMIT; setOffset(o); load(o, sort.col, sort.dir, filters) } + async function openPanel(row) { + const id = row.id + if (!id) return + setPanelLoading(true) + setPanelMsg(null) + setSelectedRecord(null) + setOverrideDraft({}) + try { + const rec = await api.getRecord(id) + setSelectedRecord(rec) + setOverrideDraft(rec.overrides || {}) + } catch (err) { + setPanelMsg({ text: err.message, ok: false }) + } finally { + setPanelLoading(false) + } + } + + function closePanel() { + setSelectedRecord(null) + setOverrideDraft({}) + setPanelMsg(null) + } + + async function handleSaveOverrides() { + if (!selectedRecord) return + setPanelSaving(true) + setPanelMsg(null) + try { + const updated = await api.setRecordOverrides(selectedRecord.id, overrideDraft) + setSelectedRecord(updated) + setOverrideDraft(updated.overrides || {}) + setPanelMsg({ text: 'Saved.', ok: true }) + // Refresh the row in the table + setRows(rs => rs.map(r => r.id === updated.id + ? { ...r, _overridden: updated.overrides != null } + : r + )) + } catch (err) { + setPanelMsg({ text: err.message, ok: false }) + } finally { + setPanelSaving(false) + } + } + + async function handleClearOverrides() { + if (!selectedRecord) return + setPanelSaving(true) + setPanelMsg(null) + try { + const updated = await api.clearRecordOverrides(selectedRecord.id) + setSelectedRecord(updated) + setOverrideDraft({}) + setPanelMsg({ text: 'Overrides cleared. Transformed values restored.', ok: true }) + setRows(rs => rs.map(r => r.id === updated.id ? { ...r, _overridden: false } : r)) + } catch (err) { + setPanelMsg({ text: err.message, ok: false }) + } finally { + setPanelSaving(false) + } + } + if (!source) return
Loading…
} + {/* Filter bar */} + {exists !== false && visCols.length > 0 && ( +View error: {viewError} — check field types in Sources.
- )} + {loading &&Loading…
} - {!loading && exists === false && ( -- No view generated yet. Go to Sources, check fields as In view, then click Generate view. -
- )} + {!loading && viewError && ( +View error: {viewError} — check field types in Sources.
+ )} - {!loading && exists && rows.length === 0 && ( -- {filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'} -
- )} + {!loading && exists === false && ( ++ No view generated yet. Go to Sources, check fields as In view, then click Generate view. +
+ )} - {!loading && exists && rows.length > 0 && ( - <> -| toggleSort(col)} - className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600" - > - {col} - - {active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'} - - | - ) - })} -||
|---|---|---|
| - {formatted === null ? — : formatted} - | +toggleSort(col)} + className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600" + > + {col} + + {active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'} + + | ) })}
|---|
Loading…
} + + {selectedRecord && !panelLoading && ( +