diff --git a/api/routes/records.js b/api/routes/records.js index b80399e..40d9579 100644 --- a/api/routes/records.js +++ b/api/routes/records.js @@ -49,6 +49,38 @@ module.exports = (pool) => { } }); + // Set overrides for all selected records and immediately merge into transformed + router.post('/bulk-overrides', async (req, res, next) => { + try { + const { source_name, record_ids, overrides } = req.body; + if (!source_name || !record_ids || !Array.isArray(record_ids) || record_ids.length === 0 || !overrides || typeof overrides !== 'object') { + return res.status(400).json({ error: 'source_name, record_ids array, and overrides object required' }); + } + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const id of record_ids) { + await client.query( + `UPDATE dataflow.records + SET overrides = COALESCE(overrides, '{}'::jsonb) || $1, + transformed = transformed || $1 + WHERE id = $2 AND source_name = $3`, + [overrides, id, source_name] + ); + } + await client.query('COMMIT'); + res.json({ updated: record_ids.length }); + } catch (err) { + await client.query('ROLLBACK'); + next(err); + } finally { + client.release(); + } + } catch (err) { + next(err); + } + }); + // Set overrides for a record and immediately merge into transformed router.put('/:id/overrides', async (req, res, next) => { try { diff --git a/ui/src/api.js b/ui/src/api.js index f98bdb6..a7c39da 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -136,6 +136,7 @@ export const api = { request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`), getRecord: (id) => request('GET', `/records/${id}`), getOverrideKeys: (source) => request('GET', `/sources/${source}/override-keys`), + setBulkRecordOverrides: (source, recordIds, overrides) => request('POST', `/records/bulk-overrides`, { source_name: source, record_ids: recordIds, overrides }), 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 7836e36..209e51b 100644 --- a/ui/src/pages/Records.jsx +++ b/ui/src/pages/Records.jsx @@ -87,8 +87,10 @@ export default function Records({ source }) { const [loading, setLoading] = useState(false) const [viewError, setViewError] = useState(null) const [sort, setSort] = useState({ col: null, dir: 'asc' }) - const [filters, setFilters] = useState([]) - const debounceRef = useRef(null) + const [filters, setFilters] = useState([]) // DB sort/filter queries + const [rowFilter, setRowFilter] = useState('') // regex filter for selecting rows + const [selected, setSelected] = useState(new Set()) // row IDs selected for bulk override + const [bulkDraft, setBulkDraft] = useState({}) // bulk override values const LIMIT = 100 // Override cols — loaded from DB once per source, extended by user via + @@ -104,6 +106,7 @@ export default function Records({ source }) { const [panelLoading, setPanelLoading] = useState(false) const [panelSaving, setPanelSaving] = useState(false) const [panelMsg, setPanelMsg] = useState(null) + const debounceRef = useRef(null) useEffect(() => { if (!source) return @@ -119,8 +122,26 @@ export default function Records({ source }) { load(0, null, 'asc', []) api.getOverrideKeys(source).then(setOverrideCols).catch(() => {}) api.getGlobalValues().then(setGlobalValues).catch(() => {}) + setSelected(new Set()) + setBulkDraft({}) + setRowFilter('') }, [source]) + // Auto-select all rows matching the regex filter when it changes + useEffect(() => { + if (!rowFilter) return + let re = null + try { re = new RegExp(rowFilter, 'i') } catch { return } + const matches = rows.filter(r => { + for (const col of displayCols) { + const val = r[col] + if (val != null && re.test(String(val))) return true + } + return false + }) + setSelected(new Set(matches.map(r => r.id))) + }, [rowFilter, rows]) + async function load(off, col, dir, filt) { setLoading(true) try { @@ -158,6 +179,7 @@ export default function Records({ source }) { function removeFilter(i) { const next = filters.filter((_, idx) => idx !== i) setFilters(next) + setSelected(new Set()) setOffset(0) load(0, sort.col, sort.dir, next) } @@ -165,12 +187,13 @@ export default function Records({ source }) { function updateFilter(i, key, val) { const next = filters.map((f, idx) => idx === i ? { ...f, [key]: val } : f) setFilters(next) + setSelected(new Set()) setOffset(0) triggerLoad(0, sort.col, sort.dir, next) } - 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) } + function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); setSelected(new Set()); load(o, sort.col, sort.dir, filters) } + function next() { const o = offset + LIMIT; setOffset(o); setSelected(new Set()); load(o, sort.col, sort.dir, filters) } async function openPanel(row) { setPanelOpen(true) @@ -299,6 +322,69 @@ export default function Records({ source }) { )} + {/* Row filter + Bulk override bar */} + {exists && visCols.length > 0 && ( +
+ setRowFilter(e.target.value)} + /> + {rowFilter && ( + {selected.size} selected + )} + {selected.size > 0 && ( +
+ {allOverrideCols.map(col => ( + setBulkDraft(d => ({ ...d, [col]: v }))} + suggestions={[...(globalValues[col] || [])].sort()} + /> + ))} + + +
+ )} +
+ )} + {loading &&

Loading…

} {!loading && viewError &&

View error: {viewError} — check field types in Sources.

} {!loading && exists === false && ( @@ -318,6 +404,17 @@ export default function Records({ source }) { + {displayCols.map(col => { const active = sort.col === col return ( @@ -333,11 +430,23 @@ export default function Records({ source }) { {rows.map((row, i) => { const isOverridden = row._overridden - const isSelected = selectedRow?.id != null && selectedRow.id === row.id + const isRowSelected = selected.has(row.id) + const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id return ( openPanel(row)} className={`border-t border-gray-50 cursor-pointer transition-colors - ${isSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}> + ${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}> + {displayCols.map((col, j) => { const formatted = formatVal(row[col]) return (
+ 0 && rows.every(r => selected.has(r.id))} + onChange={e => { + if (e.target.checked) setSelected(new Set(rows.map(r => r.id))) + else setSelected(new Set()) + }} + /> +
+ { + e.stopPropagation() + setSelected(s => { const n = new Set(s); n.has(row.id) ? n.delete(row.id) : n.add(row.id); return n }) + }} + /> +