diff --git a/internal/check-share-policy.mjs b/internal/check-share-policy.mjs index f2e715082..122bb8772 100644 --- a/internal/check-share-policy.mjs +++ b/internal/check-share-policy.mjs @@ -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') } } } diff --git a/library/composables/useCsvExport.js b/library/composables/useCsvExport.js index 2c0df6407..018e175ad 100644 --- a/library/composables/useCsvExport.js +++ b/library/composables/useCsvExport.js @@ -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 diff --git a/library/composables/useShareableView.js b/library/composables/useShareableView.js index 973484d40..f62b85a8f 100644 --- a/library/composables/useShareableView.js +++ b/library/composables/useShareableView.js @@ -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 */ } }) } diff --git a/plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs b/plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs index 4a5e2419a..40e00db9d 100644 --- a/plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs +++ b/plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs @@ -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)}`) } }