Add bulk override: DB function, API route, UI select bar
- bulk_set_record_overrides() DB function merges overrides into multiple records at once using a CTE with RETURNING for accurate count - POST /records/bulk-overrides calls the function (consistent with rest of API — no raw SQL in routes) - UI: regex input on loaded rows selects rows for bulk override; labeled "Bulk select:" / "DB query:" to distinguish from server-side filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
814dcb7af1
commit
e5b95e7112
@ -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
|
// Set overrides for a record and immediately merge into transformed
|
||||||
router.put('/:id/overrides', async (req, res, next) => {
|
router.put('/:id/overrides', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -51,6 +51,20 @@ RETURNS dataflow.records AS $$
|
|||||||
RETURNING *;
|
RETURNING *;
|
||||||
$$ LANGUAGE sql;
|
$$ 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
|
-- Clear overrides; caller should reprocess to restore computed transformed value
|
||||||
CREATE OR REPLACE FUNCTION clear_record_overrides(p_id INT)
|
CREATE OR REPLACE FUNCTION clear_record_overrides(p_id INT)
|
||||||
RETURNS dataflow.records AS $$
|
RETURNS dataflow.records AS $$
|
||||||
|
|||||||
@ -136,6 +136,7 @@ export const api = {
|
|||||||
request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`),
|
request('GET', `/records/source/${source}?limit=${limit}&offset=${offset}`),
|
||||||
getRecord: (id) => request('GET', `/records/${id}`),
|
getRecord: (id) => request('GET', `/records/${id}`),
|
||||||
getOverrideKeys: (source) => request('GET', `/sources/${source}/override-keys`),
|
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 }),
|
setRecordOverrides: (id, overrides) => request('PUT', `/records/${id}/overrides`, { overrides }),
|
||||||
clearRecordOverrides: (id) => request('DELETE', `/records/${id}/overrides`),
|
clearRecordOverrides: (id) => request('DELETE', `/records/${id}/overrides`),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,8 +87,10 @@ export default function Records({ source }) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [viewError, setViewError] = useState(null)
|
const [viewError, setViewError] = useState(null)
|
||||||
const [sort, setSort] = useState({ col: null, dir: 'asc' })
|
const [sort, setSort] = useState({ col: null, dir: 'asc' })
|
||||||
const [filters, setFilters] = useState([])
|
const [filters, setFilters] = useState([]) // DB sort/filter queries
|
||||||
const debounceRef = useRef(null)
|
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
|
const LIMIT = 100
|
||||||
|
|
||||||
// Override cols — loaded from DB once per source, extended by user via +
|
// 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 [panelLoading, setPanelLoading] = useState(false)
|
||||||
const [panelSaving, setPanelSaving] = useState(false)
|
const [panelSaving, setPanelSaving] = useState(false)
|
||||||
const [panelMsg, setPanelMsg] = useState(null)
|
const [panelMsg, setPanelMsg] = useState(null)
|
||||||
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!source) return
|
if (!source) return
|
||||||
@ -119,8 +122,26 @@ export default function Records({ source }) {
|
|||||||
load(0, null, 'asc', [])
|
load(0, null, 'asc', [])
|
||||||
api.getOverrideKeys(source).then(setOverrideCols).catch(() => {})
|
api.getOverrideKeys(source).then(setOverrideCols).catch(() => {})
|
||||||
api.getGlobalValues().then(setGlobalValues).catch(() => {})
|
api.getGlobalValues().then(setGlobalValues).catch(() => {})
|
||||||
|
setSelected(new Set())
|
||||||
|
setBulkDraft({})
|
||||||
|
setRowFilter('')
|
||||||
}, [source])
|
}, [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) {
|
async function load(off, col, dir, filt) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -158,6 +179,7 @@ export default function Records({ source }) {
|
|||||||
function removeFilter(i) {
|
function removeFilter(i) {
|
||||||
const next = filters.filter((_, idx) => idx !== i)
|
const next = filters.filter((_, idx) => idx !== i)
|
||||||
setFilters(next)
|
setFilters(next)
|
||||||
|
setSelected(new Set())
|
||||||
setOffset(0)
|
setOffset(0)
|
||||||
load(0, sort.col, sort.dir, next)
|
load(0, sort.col, sort.dir, next)
|
||||||
}
|
}
|
||||||
@ -165,12 +187,13 @@ export default function Records({ source }) {
|
|||||||
function updateFilter(i, key, val) {
|
function updateFilter(i, key, val) {
|
||||||
const next = filters.map((f, idx) => idx === i ? { ...f, [key]: val } : f)
|
const next = filters.map((f, idx) => idx === i ? { ...f, [key]: val } : f)
|
||||||
setFilters(next)
|
setFilters(next)
|
||||||
|
setSelected(new Set())
|
||||||
setOffset(0)
|
setOffset(0)
|
||||||
triggerLoad(0, sort.col, sort.dir, next)
|
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 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); 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) {
|
async function openPanel(row) {
|
||||||
setPanelOpen(true)
|
setPanelOpen(true)
|
||||||
@ -269,6 +292,7 @@ export default function Records({ source }) {
|
|||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
{exists !== false && visCols.length > 0 && (
|
{exists !== false && visCols.length > 0 && (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-xs text-gray-400 font-medium mr-1">DB query:</span>
|
||||||
{filters.map((f, i) => (
|
{filters.map((f, i) => (
|
||||||
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
|
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1">
|
||||||
<select
|
<select
|
||||||
@ -299,6 +323,70 @@ export default function Records({ source }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bulk select + override bar */}
|
||||||
|
{exists && visCols.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span>
|
||||||
|
<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="regex on loaded rows…"
|
||||||
|
value={rowFilter}
|
||||||
|
onChange={e => setRowFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
{rowFilter && (
|
||||||
|
<span className="text-xs text-gray-400">{selected.size} of {rows.length} rows 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 && <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 && viewError && <p className="text-sm text-red-500">View error: {viewError} — check field types in Sources.</p>}
|
||||||
{!loading && exists === false && (
|
{!loading && exists === false && (
|
||||||
@ -318,6 +406,17 @@ export default function Records({ source }) {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
|
<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 => {
|
{displayCols.map(col => {
|
||||||
const active = sort.col === col
|
const active = sort.col === col
|
||||||
return (
|
return (
|
||||||
@ -333,11 +432,23 @@ export default function Records({ source }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const isOverridden = row._overridden
|
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 (
|
return (
|
||||||
<tr key={i} onClick={() => openPanel(row)}
|
<tr key={i} onClick={() => openPanel(row)}
|
||||||
className={`border-t border-gray-50 cursor-pointer transition-colors
|
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) => {
|
{displayCols.map((col, j) => {
|
||||||
const formatted = formatVal(row[col])
|
const formatted = formatVal(row[col])
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user