diff --git a/api/routes/records.js b/api/routes/records.js index b80399e..4241309 100644 --- a/api/routes/records.js +++ b/api/routes/records.js @@ -49,6 +49,22 @@ 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 || !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 idList = record_ids.map(id => parseInt(id)).join(','); + const result = await pool.query( + `SELECT bulk_set_record_overrides(${lit(source_name)}, ARRAY[${idList}]::int[], ${lit(overrides)}) as updated` + ); + res.json({ updated: Number(result.rows[0].updated) }); + } 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/database/queries/records.sql b/database/queries/records.sql index f48e26b..410b338 100644 --- a/database/queries/records.sql +++ b/database/queries/records.sql @@ -51,6 +51,20 @@ RETURNS dataflow.records AS $$ RETURNING *; $$ LANGUAGE sql; +-- Merge overrides into multiple records at once; returns actual updated count +CREATE OR REPLACE FUNCTION bulk_set_record_overrides(p_source_name TEXT, p_ids INT[], p_overrides JSONB) +RETURNS BIGINT AS $$ + WITH updated AS ( + UPDATE dataflow.records + SET overrides = COALESCE(overrides, '{}'::jsonb) || p_overrides, + transformed = COALESCE(transformed, data) || p_overrides + WHERE id = ANY(p_ids) + AND source_name = p_source_name + RETURNING id + ) + SELECT count(*) FROM updated; +$$ 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 $$ 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..e91f66b 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) @@ -269,6 +292,7 @@ export default function Records({ source }) { {/* Filter bar */} {exists !== false && visCols.length > 0 && (
+ DB query: {filters.map((f, i) => (
setRowFilter(e.target.value)} + /> + {rowFilter && ( + {selected.size} of {rows.length} rows 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 +406,17 @@ export default function Records({ source }) { + {displayCols.map(col => { const active = sort.col === col return ( @@ -333,11 +432,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 }) + }} + /> +