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 && ( + + )} + > + ) +} + // ── 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 })