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:
parent
19a3b7a006
commit
e2cebd9130
4 changed files with 128 additions and 13 deletions
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue