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) <noreply@anthropic.com>
This commit is contained in:
parent
088b6a30c5
commit
b65da53360
@ -225,10 +225,26 @@ module.exports = function(pool) {
|
|||||||
return res.status(400).json({ error: `"${col}" is not a key column` });
|
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 { 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(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT "${col}" AS val FROM ${schema}.${tname}
|
`SELECT DISTINCT "${col}"::text AS val FROM ${schema}.${tname}
|
||||||
WHERE "${col}" IS NOT NULL ORDER BY "${col}"`
|
${filter} ORDER BY 1 LIMIT $${params.length}`,
|
||||||
|
params
|
||||||
);
|
);
|
||||||
res.json(result.rows.map(r => r.val));
|
res.json(result.rows.map(r => r.val));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
// flex-1 buttons grew to absurd sizes; everything here is fixed-width and
|
// flex-1 buttons grew to absurd sizes; everything here is fixed-width and
|
||||||
// left-aligned instead.
|
// 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 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'
|
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 (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
list={col.is_key ? listId : undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder="keep"
|
||||||
|
className={className} />
|
||||||
|
{col.is_key && (
|
||||||
|
<datalist id={listId}>
|
||||||
|
{options.map(o => <option key={o} value={o} />)}
|
||||||
|
</datalist>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2b. Recode / clone form ─────────────────────────────────────────────────
|
// ── 2b. Recode / clone form ─────────────────────────────────────────────────
|
||||||
// Same pairing: the dimension's current value sits beside the box that replaces it.
|
// 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 multi = slices.length > 1
|
||||||
const first = slices[0] || {}
|
const first = slices[0] || {}
|
||||||
return (
|
return (
|
||||||
@ -563,13 +603,14 @@ function DimForm({ dimCols, setObj, setSet, slices, lookupDerivedCols, extra })
|
|||||||
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
|
<td className="pr-3 py-0.5 text-gray-500 whitespace-nowrap" title={c.cname}>{c.label || c.cname}</td>
|
||||||
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
|
<td className="px-2 py-0.5 font-mono text-gray-600 max-w-[10rem] truncate" title={String(cur)}>{cur}</td>
|
||||||
<td className="pl-2 py-0.5">
|
<td className="pl-2 py-0.5">
|
||||||
<input
|
<DimValueInput
|
||||||
|
col={c}
|
||||||
|
sourceId={sourceId}
|
||||||
value={setObj[c.cname] || ''}
|
value={setObj[c.cname] || ''}
|
||||||
onChange={e => setSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
onChange={e => setSet(s => ({ ...s, [c.cname]: e.target.value }))}
|
||||||
onBlur={c.is_key && c.dim_group
|
onBlur={c.is_key && c.dim_group
|
||||||
? e => lookupDerivedCols(c.cname, e.target.value, setSet)
|
? e => lookupDerivedCols(c.cname, e.target.value, setSet)
|
||||||
: undefined}
|
: undefined}
|
||||||
placeholder="keep"
|
|
||||||
className={TEXT} />
|
className={TEXT} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -661,7 +702,7 @@ export default function OperationPanel({
|
|||||||
cloneSet, setCloneSet,
|
cloneSet, setCloneSet,
|
||||||
cloneScale, setCloneScale,
|
cloneScale, setCloneScale,
|
||||||
cloneNote, setCloneNote,
|
cloneNote, setCloneNote,
|
||||||
dimCols, lookupDerivedCols,
|
dimCols, lookupDerivedCols, sourceId,
|
||||||
buildPayload, submitOp,
|
buildPayload, submitOp,
|
||||||
}) {
|
}) {
|
||||||
const hasSlice = slices.length > 0
|
const hasSlice = slices.length > 0
|
||||||
@ -722,12 +763,12 @@ export default function OperationPanel({
|
|||||||
)}
|
)}
|
||||||
{activeOp === 'recode' && (
|
{activeOp === 'recode' && (
|
||||||
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
|
<DimForm dimCols={dimCols} setObj={recodeSet} setSet={setRecodeSet}
|
||||||
slices={slices} lookupDerivedCols={lookupDerivedCols}
|
slices={slices} lookupDerivedCols={lookupDerivedCols} sourceId={sourceId}
|
||||||
extra={<MovingTotal currentTotals={currentTotals} verb="Moving" />} />
|
extra={<MovingTotal currentTotals={currentTotals} verb="Moving" />} />
|
||||||
)}
|
)}
|
||||||
{activeOp === 'clone' && (
|
{activeOp === 'clone' && (
|
||||||
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
|
<DimForm dimCols={dimCols} setObj={cloneSet} setSet={setCloneSet}
|
||||||
slices={slices} lookupDerivedCols={lookupDerivedCols}
|
slices={slices} lookupDerivedCols={lookupDerivedCols} sourceId={sourceId}
|
||||||
extra={
|
extra={
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -1257,7 +1257,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
cloneSet, setCloneSet,
|
cloneSet, setCloneSet,
|
||||||
cloneScale, setCloneScale,
|
cloneScale, setCloneScale,
|
||||||
cloneNote, setCloneNote,
|
cloneNote, setCloneNote,
|
||||||
dimCols, lookupDerivedCols,
|
dimCols, lookupDerivedCols, sourceId,
|
||||||
buildPayload, submitOp,
|
buildPayload, submitOp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user