Shared links keep partner param names, and a CSV export matches multi-value filters

A shared table link can now be written as one query param per facet, and a
param name may map to a different bus key, so links already published on
partner pages keep working. The CSV export matched no rows once a reader picked
a second value in a filter; the default row match now reads a multi-value filter
as OR, and the gate no longer exempts that line.
This commit is contained in:
Doxa Maps 2026-09-27 18:40:47 +00:00
parent 19a3b7a006
commit e2cebd9130
4 changed files with 128 additions and 13 deletions

View file

@ -150,9 +150,9 @@ if (debt.length) {
if (!src) fails.push(`SHARE-VIEW ${SRC} is missing — no dashboard can produce a shareable view.`)
else {
const pure = src.slice(src.indexOf('const slugify'), src.indexOf('export function useShareableView'))
let serializeView, parseView
let serializeView, parseView, serializeQuery, parseQuery
try {
;({ serializeView, parseView } = new Function(`${pure.replace(/export /g, '')}; return { serializeView, parseView }`)())
;({ serializeView, parseView, serializeQuery, parseQuery } = new Function(`${pure.replace(/export /g, '')}; return { serializeView, parseView, serializeQuery, parseQuery }`)())
} catch (e) {
fails.push(`SHARE-VIEW could not lift serializeView/parseView from ${SRC} (${e.message}) — keep them plain top-level functions so this stays measurable.`)
}
@ -173,7 +173,26 @@ if (debt.length) {
// Links that already exist must keep opening.
eq('a legacy JSON hash still restores', parseView('#f=' + encodeURIComponent(JSON.stringify({ affinity_bloc: 'A008' }))).filters, { affinity_bloc: 'A008' })
eq('an old link with no q restores no search', parseView('#f=religion_family:Hinduism').search, '')
if (!fails.some((f) => f.startsWith('SHARE-VIEW'))) notes.push('share link: filters AND the typed search round-trip; old links and legacy JSON still restore')
// QUERY MODE, requested by go-int: its board already shares as ?access=…&band=…, restored before
// first render, and writes only on its own leaves so a partner's address bar is never touched. A
// hash-only composable would have given that board TWO URL states instead of replacing its glue.
// The host-param case is the one that matters: writing must not eat a query the page already owns.
eq('query writes one param per facet', serializeQuery({ access: 'low', band: '2g' }, ''), '?access=low&band=2g')
eq('query preserves a host param', serializeQuery({ access: 'low' }, '', '?utm_source=partner'), '?utm_source=partner&access=low')
eq('query replaces its own stale param', serializeQuery({ access: 'high' }, '', '?access=low&utm=x'), '?utm=x&access=high')
eq('query round-trips filters and search', parseQuery(serializeQuery({ access: 'low', band: ['2g', '3g'] }, 'bhutan'), ['access', 'band']), { filters: { access: 'low', band: ['2g', '3g'] }, search: 'bhutan' })
eq('query reads only declared facet keys', parseQuery('?access=low&panel=map', ['access']).filters, { access: 'low' })
eq('query keeps a comma inside a value', parseQuery(serializeQuery({ language: ['Arabic, Sudanese'] }, ''), ['language']).filters, { language: 'Arabic, Sudanese' })
// ★ AN ALIAS MAP, because a param name already published cannot be renamed. go-int has shared
// ?access= against a bus key of access_level; insisting the two match would have renamed every
// param on adoption and broken every link already sitting on a partner page — the exact harm this
// card exists to prevent, delivered by the thing meant to prevent it. Driven with its real names.
const ALIAS = { access: 'access_level', app: 'messaging' }
eq('alias writes the published param name', serializeQuery({ access_level: 'low', messaging: 'sms' }, '', '', { facetKeys: ALIAS }), '?access=low&app=sms')
eq('alias reads an already-shared link', parseQuery('?access=low&app=sms', ALIAS), { filters: { access_level: 'low', messaging: 'sms' }, search: '' })
eq('an undeclared bus key keeps its own name', serializeQuery({ country: 'BT' }, '', '', { facetKeys: ALIAS }), '?country=BT')
eq('a host param survives under an alias map', serializeQuery({ access_level: 'low' }, '', '?utm=x', { facetKeys: ALIAS }), '?utm=x&access=low')
if (!fails.some((f) => f.startsWith('SHARE-VIEW'))) notes.push('share link: filters AND the typed search round-trip in BOTH hash and query mode; old links, legacy JSON and a host page\'s own query all survive')
}
}
}

View file

@ -102,7 +102,19 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// is the config-level spelling of the same thing; go-int flagged the pair, and they should agree.
keyOf = (r) => r && (r.slug || r.id),
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
// ★ THE DEFAULT IS ARRAY-AWARE, because it is the LIVE path, not a legacy shim. A filterSet value is
// an array once two values are selected (spec D1), and `a(r) === ['Hinduism','Islam']` is false for
// every row — so the export silently produced a file matching NOTHING the moment a reader picked a
// second value. Measured: DashboardDataTable and DashboardDetailDrawer both rely on this default
// (only useDashboardShell passes its own), so two of the three live callers exported an empty set.
// OR within a key, the same rule bus.rowMatchesFilters applies, so the CSV agrees with the screen.
// Single-value behaviour is unchanged: one value takes exactly the path it always did.
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => {
const a = ACC[k]
if (!a) return true
const want = filterSet[k]
return Array.isArray(want) ? want.some((v) => a(r) === v) : a(r) === want
}) }) {
// slug -> wide API record, filled by the lazy all-fields fetch below.
let wideStore = null
let widePromise = null

View file

@ -35,6 +35,13 @@
* Injected, never imported: the label tables are the consuming dashboard's, not the kit's.
* @param {string} [options.hashKey='f'] the filter param name, for a host that already owns `f`
* @param {string} [options.searchKey='q'] the search param name, same reason
* @param {'hash'|'query'} [options.mode='hash'] where the state lives. 'query' writes one param per
* facet (?access=…&band=…&q=…) instead of a single #f= blob — go-int's board already shares that
* way, restored before first render, and a composable that only spoke hash would have given it
* TWO URL states rather than replacing its glue. Requested by the consumer, not invented here.
* @param {boolean} [options.write=true] whether to WRITE the URL at all. go-int writes only on its own
* leaves (profile-config urlState:'query') so a partner's address bar is never touched; reading
* stays unconditional, because restoring a link someone sent must work everywhere.
*/
import { watch, onMounted } from 'vue'
@ -87,21 +94,94 @@ export function parseView(hash, { hashKey = 'f', searchKey = 'q' } = {}) {
return out
}
/**
* ★ A PARAM NAME IS NOT ALWAYS THE BUS KEY, AND RENAMING ONE BREAKS LINKS THAT ARE ALREADY OUT.
* go-int's board has shared `?access=` for a while against a bus key of `access_level` (and detail ->
* access_detail, wayin -> way_in, app -> messaging). Those links sit on partner pages today. If this
* composable insisted that the param equal the key, adopting it would silently rename every param and
* every one of those links would stop restoring — the exact harm the shareable-view card exists to
* prevent, caused by the thing meant to deliver it.
* So `facetKeys` takes EITHER shape:
* ['access_level', 'country'] param name === bus key
* { access: 'access_level', app: 'messaging' } param -> bus key, for names already in the wild
* An array stays the common case; the map is for a URL vocabulary you have already published.
*/
const busKeyFor = (facetKeys) => {
if (Array.isArray(facetKeys)) { const k = new Set(facetKeys); return (p) => (k.has(p) ? p : null) }
const m = facetKeys && typeof facetKeys === 'object' ? facetKeys : {}
return (p) => (Object.prototype.hasOwnProperty.call(m, p) ? m[p] : null)
}
const paramNameFor = (facetKeys) => {
if (!facetKeys || Array.isArray(facetKeys)) return (k) => k
const rev = Object.fromEntries(Object.entries(facetKeys).map(([param, key]) => [key, param]))
// A bus key with no declared param keeps its own name rather than being dropped: a filter that
// cannot be written is a filter that silently will not survive the link.
return (k) => (Object.prototype.hasOwnProperty.call(rev, k) ? rev[k] : k)
}
/**
* Serialize to a QUERY string, one param per facet. Preserves params this composable does not own —
* a host page's own query survives, which is the whole point of writing only on your own leaves.
*/
export function serializeQuery(filterSet, searchTerm, existing = '', { searchKey = 'q', facetKeys } = {}) {
const paramOf = paramNameFor(facetKeys)
const keep = []
const mine = new Set([...Object.keys(filterSet || {}).map(paramOf), searchKey])
for (const part of String(existing || '').replace(/^\?/, '').split('&')) {
if (!part) continue
const k = part.split('=')[0]
if (!mine.has(k)) keep.push(part)
}
const pairs = Object.entries(filterSet || {}).map(([k, v]) =>
paramOf(k) + '=' + (Array.isArray(v) ? v.map(escapeValue).join(',') : escapeValue(v)))
const term = String(searchTerm ?? '').trim()
if (term) pairs.push(searchKey + '=' + escapeValue(term))
const all = [...keep, ...pairs]
return all.length ? '?' + all.join('&') : ''
}
/** Parse a QUERY string. Only the keys the caller declares as facets are read as filters. */
export function parseQuery(search, facetKeys = [], { searchKey = 'q' } = {}) {
const out = { filters: {}, search: '' }
const keyOf = busKeyFor(facetKeys)
for (const part of String(search || '').replace(/^\?/, '').split('&')) {
if (!part) continue
const i = part.indexOf('=')
if (i < 1) continue
const k = part.slice(0, i)
const body = part.slice(i + 1)
if (k === searchKey) { out.search = unescapeValue(body); continue }
const busKey = keyOf(k)
if (!busKey) continue
const parts = body.split(',').map(unescapeValue).filter(Boolean)
if (parts.length) out.filters[busKey] = parts.length > 1 ? parts : parts[0]
}
return out
}
export function useShareableView(bus, options = {}) {
const write = () => {
const { mode = 'hash', write: shouldWrite = true, facetKeys } = options
const push = () => {
try {
if (typeof location === 'undefined') return
history.replaceState(null, '', serializeView(bus.filterSet, bus.searchTerm?.value, options))
if (typeof location === 'undefined' || !shouldWrite) return
const url = mode === 'query'
? serializeQuery(bus.filterSet, bus.searchTerm?.value, location.search, { ...options, facetKeys }) + location.hash
: location.search + serializeView(bus.filterSet, bus.searchTerm?.value, options)
history.replaceState(null, '', location.pathname + url)
} catch (_) { /* sandboxed */ }
}
watch(() => ({ ...bus.filterSet, __q: bus.searchTerm?.value }), write, { deep: true })
watch(() => ({ ...bus.filterSet, __q: bus.searchTerm?.value }), push, { deep: true })
onMounted(() => {
try {
if (typeof location === 'undefined') return
const { filters, search } = parseView(location.hash, options)
// READING is unconditional even when writing is off: restoring a link someone sent has to work
// on a partner's page too, which is exactly where writing must not happen.
const { filters, search } = mode === 'query'
? parseQuery(location.search, facetKeys || Object.keys(bus.filterSet || {}), options)
: parseView(location.hash, options)
Object.entries(filters).forEach(([k, v]) => bus.setFilter(k, v))
if (search && typeof bus.setSearch === 'function') bus.setSearch(search)
} catch (_) { /* ignore bad hash */ }
} catch (_) { /* ignore a bad URL */ }
})
}

View file

@ -103,9 +103,13 @@ const { createSelectionBus } = await import(BUS)
for (const [i, line] of readFileSync(p, 'utf8').split('\n').entries()) {
if (/^\s*(\/\/|\*)/.test(line)) continue // a comment quoting the old rule is fine
if (!/===\s*(bus\.)?filterSet\[/.test(line)) continue
// THE ONE ALLOWED SITE, named rather than pattern-matched: the exporter's parameter default,
// which keeps a pre-D1 caller working instead of throwing.
if (rel.endsWith('useCsvExport.js') && /rowMatches\s*=/.test(line)) continue
// ★ THE EXEMPTION THAT USED TO SIT HERE IS GONE, AND IT WAS WRONG. It allowed
// useCsvExport's default rowMatches to keep `=== filterSet[k]`, reasoning that the default only
// "keeps a pre-D1 caller working". MEASURED: there is no such caller. DashboardDataTable and
// DashboardDetailDrawer both use that default, so it was the LIVE path for two of three callers,
// and a multi-value filter made the export match nothing at all. An exemption is a claim about
// who is affected; this one was never checked, and the gate stayed green over a real defect on
// the file it was pointed at. The default is array-aware now, so nothing needs excusing.
fails.push(`B ${rel}:${i + 1} still compares with === against filterSet — with an array that matches NOTHING, and this panel would disagree with the others about what is on screen.\n ${line.trim().slice(0, 140)}`)
}
}