Add recode/clone dim_group sibling auto-lookup

- GET /api/sources/:id/lookup?col=X&value=Y — given a key column value, queries the source table for sibling column values in the same dim_group; returns null if no match or ambiguous
- Recode and Clone panels: key columns (is_key + dim_group) trigger lookup on blur and auto-fill sibling inputs that the user hasn't already typed into
- Row labels now use col_meta label field when set, falling back to cname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-05-23 09:55:31 -04:00
parent 2a9a3be0f0
commit cf9bdea9a8
2 changed files with 68 additions and 6 deletions

View File

@ -224,6 +224,41 @@ module.exports = function(pool) {
} }
}); });
// given a key column value, look up sibling dim_group column values from source
// returns { sibling_col: value, ... } if exactly one match, null if none or ambiguous
router.get('/sources/:id/lookup', async (req, res) => {
const { col, value } = req.query;
if (!col || value == null || value === '') return res.json(null);
try {
const [srcResult, metaResult] = await Promise.all([
pool.query(`SELECT schema, tname FROM pf.source WHERE id = $1`, [req.params.id]),
pool.query(`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`, [req.params.id])
]);
if (srcResult.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
const keyCol = metaResult.rows.find(c => c.cname === col && c.is_key && c.dim_group);
if (!keyCol) return res.json(null);
const siblings = metaResult.rows.filter(c =>
c.dim_group === keyCol.dim_group && c.cname !== col
);
if (!siblings.length) return res.json(null);
const { schema, tname } = srcResult.rows[0];
const sibCols = siblings.map(c => `"${c.cname}"`).join(', ');
const result = await pool.query(
`SELECT DISTINCT ${sibCols} FROM "${schema}"."${tname}" WHERE "${col}" = $1 LIMIT 2`,
[value]
);
if (result.rows.length !== 1) return res.json(null);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// set or clear the default Perspective layout for a source. // set or clear the default Perspective layout for a source.
// Body: a Perspective view config (group_by, split_by, columns, plugin_config, …). // Body: a Perspective view config (group_by, split_by, columns, plugin_config, …).
// Pass null or {} to clear. // Pass null or {} to clear.

View File

@ -436,6 +436,21 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
} catch (err) { flash(err.message, 'error') } } catch (err) { flash(err.message, 'error') }
} }
async function lookupDerivedCols(col, value, setter) {
if (!sourceId || !value.trim()) return
const res = await fetch(`/api/sources/${sourceId}/lookup?col=${encodeURIComponent(col)}&value=${encodeURIComponent(value)}`)
if (!res.ok) return
const derived = await res.json()
if (!derived) return
setter(prev => {
const next = { ...prev }
for (const [k, v] of Object.entries(derived)) {
if (!prev[k] || prev[k] === '') next[k] = String(v ?? '')
}
return next
})
}
function buildEffectiveSlice(raw) { function buildEffectiveSlice(raw) {
const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname)) const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname)) const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
@ -842,9 +857,15 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
{activeOp === 'recode' && <> {activeOp === 'recode' && <>
<p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p> <p className="text-gray-400">New values for dimensions to replace. Leave blank to keep.</p>
{dimCols.map(c => ( {dimCols.map(c => (
<Row key={c.cname} label={c.cname}> <Row key={c.cname} label={c.label || c.cname}>
<input value={recodeSet[c.cname] || ''} onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))} <input
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> value={recodeSet[c.cname] || ''}
onChange={e => setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))}
onBlur={c.is_key && c.dim_group
? e => lookupDerivedCols(c.cname, e.target.value, setRecodeSet)
: undefined}
placeholder={slice[c.cname] || '—'}
className={`${inp} font-mono`} />
</Row> </Row>
))} ))}
<Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row> <Row label="Note"><input value={recodeNote} onChange={e => setRecodeNote(e.target.value)} placeholder="optional" className={inp} /></Row>
@ -855,9 +876,15 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou
{activeOp === 'clone' && <> {activeOp === 'clone' && <>
<p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p> <p className="text-gray-400">Override dimensions on cloned rows. Leave blank to keep.</p>
{dimCols.map(c => ( {dimCols.map(c => (
<Row key={c.cname} label={c.cname}> <Row key={c.cname} label={c.label || c.cname}>
<input value={cloneSet[c.cname] || ''} onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))} <input
placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> value={cloneSet[c.cname] || ''}
onChange={e => setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))}
onBlur={c.is_key && c.dim_group
? e => lookupDerivedCols(c.cname, e.target.value, setCloneSet)
: undefined}
placeholder={slice[c.cname] || '—'}
className={`${inp} font-mono`} />
</Row> </Row>
))} ))}
<Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row> <Row label="Scale"><input type="number" step="any" value={cloneScale} onChange={e => setCloneScale(e.target.value)} className={inp} /></Row>