From b65da53360d057d2a189235bc4c39d32854dd36e Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 16 Sep 2026 23:55:37 -0400 Subject: [PATCH] Complete key dimension values as you type them in recode and clone Recoding to a part meant typing a code from memory into a plain text box, with the only feedback being that the sibling autofill either fired or silently did nothing. The values endpoint already existed but returned every distinct value with no filter and no limit, which for part on osm_skinny is 11,290 rows -- too slow to open and no easier to read than a short list. It now takes ?q= and ?limit=, so the field fetches matches for what has been typed so far, debounced 200ms, and offers them through a native datalist. Only key columns get it, which is the same condition the endpoint already enforced, and the same one that decides whether the dim_group sibling lookup runs on blur. So on osm_skinny it is part and customer: type 1601 and the eight parts containing it are offered; pick one and the nine part-group columns fill themselves. Co-Authored-By: Claude Opus 5 (1M context) --- routes/sources.js | 20 +++++++++- ui/src/components/OperationPanel.jsx | 55 ++++++++++++++++++++++++---- ui/src/views/Forecast.jsx | 2 +- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/routes/sources.js b/routes/sources.js index 326dc3e..bda2a63 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -225,10 +225,26 @@ module.exports = function(pool) { return res.status(400).json({ error: `"${col}" is not a key column` }); } + // ?q= narrows, ?limit= caps. A key column can be very wide -- part on + // osm_skinny has 11,290 distinct values -- so returning the lot to fill a + // completion list is both a slow query and a large response for a control + // that can only usefully show a handful. const { schema, tname } = srcResult.rows[0]; + const q = (req.query.q || '').trim(); + const limit = Math.min(parseInt(req.query.limit) || 5000, 5000); + + const params = []; + let filter = `WHERE "${col}" IS NOT NULL`; + if (q) { + params.push(`%${q}%`); + filter += ` AND "${col}"::text ILIKE $${params.length}`; + } + params.push(limit); + const result = await pool.query( - `SELECT DISTINCT "${col}" AS val FROM ${schema}.${tname} - WHERE "${col}" IS NOT NULL ORDER BY "${col}"` + `SELECT DISTINCT "${col}"::text AS val FROM ${schema}.${tname} + ${filter} ORDER BY 1 LIMIT $${params.length}`, + params ); res.json(result.rows.map(r => r.val)); } catch (err) { diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 70f0478..2a58b2a 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -11,7 +11,7 @@ // flex-1 buttons grew to absurd sizes; everything here is fixed-width and // left-aligned instead. -import { useState } from 'react' +import { useState, useEffect } from 'react' const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums' const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono' @@ -538,9 +538,49 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se ) } +// Completion for a key dimension. The list is fetched as you type rather than up +// front: part alone has 11,290 distinct values, and a native datalist given all of +// them is slow to open and no easier to read than a short filtered one. +function DimValueInput({ col, sourceId, value, onChange, onBlur, className }) { + const [options, setOptions] = useState([]) + const listId = `pf-vals-${col.cname}` + + useEffect(() => { + if (!sourceId || !col.is_key) return + let cancelled = false + // wait for a pause in typing; every keystroke would otherwise be a query + const t = setTimeout(async () => { + try { + const url = `/api/sources/${sourceId}/values/${encodeURIComponent(col.cname)}` + + `?limit=50${value ? `&q=${encodeURIComponent(value)}` : ''}` + const rows = await fetch(url).then(r => r.ok ? r.json() : []) + if (!cancelled) setOptions(Array.isArray(rows) ? rows : []) + } catch { if (!cancelled) setOptions([]) } + }, 200) + return () => { cancelled = true; clearTimeout(t) } + }, [sourceId, col.cname, col.is_key, value]) + + return ( + <> + + {col.is_key && ( + + {options.map(o => + )} + + ) +} + // ── 2b. Recode / clone form ───────────────────────────────────────────────── // Same pairing: the dimension's current value sits beside the box that replaces it. -function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, extra }) { +function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, sourceId, extra }) { const multi = slices.length > 1 const first = slices[0] || {} return ( @@ -563,13 +603,14 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, extra }) {c.label || c.cname} {cur} - setSet(s => ({ ...s, [c.cname]: e.target.value }))} onBlur={c.is_key && c.dim_group ? e => lookupDerivedCols(c.cname, e.target.value, setSet) : undefined} - placeholder="keep" className={TEXT} /> @@ -661,7 +702,7 @@ export default function OperationPanel({ cloneSet, setCloneSet, cloneScale, setCloneScale, cloneNote, setCloneNote, - dimCols, lookupDerivedCols, + dimCols, lookupDerivedCols, sourceId, buildPayload, submitOp, }) { const hasSlice = slices.length > 0 @@ -722,12 +763,12 @@ export default function OperationPanel({ )} {activeOp === 'recode' && ( } /> )} {activeOp === 'clone' && (
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index ae7b87f..e1c86e2 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1257,7 +1257,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio cloneSet, setCloneSet, cloneScale, setCloneScale, cloneNote, setCloneNote, - dimCols, lookupDerivedCols, + dimCols, lookupDerivedCols, sourceId, buildPayload, submitOp, }