From cf9bdea9a858f909a9f24bf7367ac9342a12cd74 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 23 May 2026 09:55:31 -0400 Subject: [PATCH] Add recode/clone dim_group sibling auto-lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- routes/sources.js | 35 +++++++++++++++++++++++++++++++++++ ui/src/views/Forecast.jsx | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/routes/sources.js b/routes/sources.js index 4347774..5b2352e 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -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. // Body: a Perspective view config (group_by, split_by, columns, plugin_config, …). // Pass null or {} to clear. diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 93192ca..94e85fd 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -436,6 +436,21 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou } 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) { 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)) @@ -842,9 +857,15 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou {activeOp === 'recode' && <>

New values for dimensions to replace. Leave blank to keep.

{dimCols.map(c => ( - - setRecodeSet(s => ({ ...s, [c.cname]: e.target.value }))} - placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> + + 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`} /> ))} setRecodeNote(e.target.value)} placeholder="optional" className={inp} /> @@ -855,9 +876,15 @@ export default function Forecast({ sources = [], sourceId, versionId, refreshSou {activeOp === 'clone' && <>

Override dimensions on cloned rows. Leave blank to keep.

{dimCols.map(c => ( - - setCloneSet(s => ({ ...s, [c.cname]: e.target.value }))} - placeholder={slice[c.cname] || '—'} className={`${inp} font-mono`} /> + + 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`} /> ))} setCloneScale(e.target.value)} className={inp} />