Compare commits

...

1 Commits

Author SHA1 Message Date
0a9685777e WIP: bulk override feature (raw SQL, needs DB function)
Saves in-progress bulk-override implementation for review.
Not merged to stacks — backend uses raw SQL instead of a DB function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:00:05 -04:00
3 changed files with 148 additions and 6 deletions

View File

@ -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 {

View File

@ -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`),
}

View File

@ -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 }) {
</div>
)}
{/* Row filter + Bulk override bar */}
{exists && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center">
<input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${
rowFilter ? 'border-blue-300' : 'border-gray-200'
}`}
placeholder="filter regex…"
value={rowFilter}
onChange={e => setRowFilter(e.target.value)}
/>
{rowFilter && (
<span className="text-xs text-gray-400">{selected.size} selected</span>
)}
{selected.size > 0 && (
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap">
{allOverrideCols.map(col => (
<AutocompleteInput
key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white"
placeholder={col}
value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
suggestions={[...(globalValues[col] || [])].sort()}
/>
))}
<button
onClick={async () => {
const overrides = Object.fromEntries(
Object.entries(bulkDraft).filter(([, v]) => v.trim())
)
if (Object.keys(overrides).length === 0) return
if (selected.size === 0) return
setPanelSaving(true)
setPanelMsg(null)
try {
const res = await api.setBulkRecordOverrides(source, [...selected], overrides)
setSelected(new Set())
setBulkDraft({})
setPanelMsg({ text: `Updated ${res.updated} records.`, ok: true })
load(offset, sort.col, sort.dir, filters)
} catch (err) {
setPanelMsg({ text: err.message, ok: false })
} finally {
setPanelSaving(false)
}
}}
disabled={panelSaving || selected.size === 0 || Object.values(bulkDraft).every(v => !v.trim())}
className="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40 whitespace-nowrap"
>
Apply to {selected.size}
</button>
<button
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
className="text-xs text-blue-400 hover:text-blue-600"
>
cancel
</button>
</div>
)}
</div>
)}
{loading && <p className="text-sm text-gray-400">Loading</p>}
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} check field types in Sources.</p>}
{!loading && exists === false && (
@ -318,6 +404,17 @@ export default function Records({ source }) {
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
<th className="px-2 py-2 w-8">
<input
type="checkbox"
className="cursor-pointer"
checked={rows.length > 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())
}}
/>
</th>
{displayCols.map(col => {
const active = sort.col === col
return (
@ -333,11 +430,23 @@ export default function Records({ source }) {
<tbody>
{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 (
<tr key={i} onClick={() => 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'}`}>
<td className="px-2 py-2">
<input
type="checkbox"
className="cursor-pointer"
checked={isRowSelected}
onChange={e => {
e.stopPropagation()
setSelected(s => { const n = new Set(s); n.has(row.id) ? n.delete(row.id) : n.add(row.id); return n })
}}
/>
</td>
{displayCols.map((col, j) => {
const formatted = formatVal(row[col])
return (