doxa-maps/library/composables/useShareableView.js
Doxa Maps e79d6dc8f4 A shared link reads a list separated by , or |, so partner links already published keep working
Links on partner pages write band=yes|partial, and the reader split on ',' only,
so such a link read back as one value and matched no row. Reading now accepts
either separator; writing stays ','. A literal '|' in a value is percent-encoded
before any join, so every separator left in a URL is a real one.
2026-09-27 19:10:08 +00:00

206 lines
12 KiB
JavaScript

/**
* useShareableView — a dashboard's view state in the address bar, so a narrowed table can be SENT.
*
* The persona this exists for: a researcher narrows the table to the peoples they are working on, then
* gives that exact view to a teammate or reopens it on a phone in the field. A link is the only part of
* the product a person copies, pastes and reads, so the format is legible on purpose:
*
* #f=affinity_bloc:A008*malay-peoples;religion_family:Hinduism&q=bhutan
*
* ── LIFTED FROM research-dashboard, AND WHY IT IS HERE ────────────────────────────────────────
* This was a PRIVATE function inside plugin-designs/research-dashboard/src/composables/
* useDashboardShell.js, so exactly one dashboard in the fleet could produce a shareable link. Every
* other dashboard built on the kit — go-int's board today, the next one after it — had no way to share
* a view at all. Nothing about the logic was research-specific; only its address was.
*
* ── THE FORMAT, AND THE TWO DECISIONS INSIDE IT (carried over, not reinvented) ─────────────────
* CODE-CANONICAL, SLUG DISPLAY-ONLY (card BUG-SHARE-URL-LEAKS-RAW-CODES). The old hash was
* URL-encoded JSON — #f=%7B%22affinity_bloc%22%3A%22A008%22%7D — unreadable twice over: JSON
* punctuation percent-escaped, and the value a bare API code. Now the CODE is canonical and `*slug` is
* appended from labelFor() at share time and STRIPPED on parse, so links stay locale-independent and
* survive label renames. A MULTI-VALUE key drops the slug entirely: there is no honest human name for
* "these twenty-two codes", and inventing one would put a label in a URL that does not describe what
* the URL selects.
*
* ── THE SEARCH TERM IS IN THE LINK, WHICH IT WAS NOT BEFORE ───────────────────────────────────
* The hash carried filterSet ONLY. Typing in the search box is the commonest way this persona narrows a
* table, so a shared link reopened with MORE ROWS than the sender was looking at — and silently, since
* the recipient had no way to know their set was wider. For population data passed between teams that
* is worse than a link that plainly fails. `q` is appended only when a term is present, so a dashboard
* with an empty search box produces byte-identically the hash it produced before.
*
* @param {object} bus a selection bus (filterSet, setFilter, searchTerm, setSearch)
* @param {object} [options]
* @param {(facetKey:string, value:any)=>string} [options.labelFor] supplies the display-only slug.
* 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'
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
// Minimal escaping, maximal legibility: reserve our delimiters + %, keep spaces as `+` (the
// x-www-form-urlencoded convention), leave everything else — including unicode — alone. `,` joins the
// values of ONE key (OR-within-key), and is reserved for the same reason `;` `:` `*` are: a delimiter
// that can also occur inside a value is not a delimiter. `&` joins params, so it is reserved too —
// without that, a search for "rice & beans" would truncate the link at the ampersand.
// `|` joins as well as `,` — see LIST_SEP below — so it is reserved too. A delimiter that can also occur
// inside a value is not a delimiter, and that rule has already cost this tree once: the language-code link
// joined on `,` until 120 of 864 language labels turned out to contain one.
const escapeValue = (s) => String(s).replace(/[%;:*+,&|]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
/**
* ★ READ BOTH `,` AND `|`; WRITE `,`. go-int's board has always written `band=yes|partial` and
* `country=IND|CHN`, and those links are on partner pages now. Splitting only on `,` read that back as the
* single value "yes|partial", which matches no row — so adopting this composable would have broken every
* multi-value link they had already shared. Accepting BOTH separators on read costs nothing: escapeValue
* percent-encodes a literal `,` or `|` inside a value before any join, so every separator still present in
* a URL is genuinely a separator.
* Writing stays `,` so there is ONE canonical form going forward rather than two dialects to reason about.
* The one case this does not cover, stated rather than papered over: an OLD link whose VALUE contained a
* literal `|` written before `|` was reserved would now be split. No facet vocabulary in the tree contains
* one (checked religion.js and the region/language codes), and go-int confirms `|` cannot occur in a facet
* code, so the exposure is a value shape that does not exist rather than one we are choosing to risk.
*/
const LIST_SEP = /[,|]/
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
/** Serialize a view to a hash string. Exported so a gate can drive it without a browser. */
export function serializeView(filterSet, searchTerm, { labelFor, hashKey = 'f', searchKey = 'q' } = {}) {
const pairs = Object.entries(filterSet || {}).map(([k, v]) => {
if (Array.isArray(v)) return k + ':' + v.map(escapeValue).join(',')
const label = typeof labelFor === 'function' ? labelFor(k, v) : null
const slug = label && String(label) !== String(v) ? slugify(label) : ''
return k + ':' + escapeValue(v) + (slug ? '*' + slug : '')
})
const term = String(searchTerm ?? '').trim()
return '#' + hashKey + '=' + pairs.join(';') + (term ? '&' + searchKey + '=' + escapeValue(term) : '')
}
/** Parse a hash into { filters, search }. Exported for the same reason. */
export function parseView(hash, { hashKey = 'f', searchKey = 'q' } = {}) {
const out = { filters: {}, search: '' }
const h = String(hash || '')
const sm = new RegExp('[#&]' + searchKey + '=([^&]*)').exec(h)
if (sm) out.search = unescapeValue(sm[1])
const m = new RegExp('[#&]' + hashKey + '=([^&]*)').exec(h)
if (!m) return out
const raw = m[1]
if (raw.startsWith('{') || raw.startsWith('%7B') || raw.startsWith('%7b')) {
// A legacy JSON hash from a link shared before the readable format. Still restores.
try { out.filters = JSON.parse(decodeURIComponent(raw)) } catch (_) { /* ignore bad hash */ }
return out
}
for (const pair of raw.split(';')) {
const i = pair.indexOf(':')
if (i < 1) continue
const key = pair.slice(0, i)
const body = pair.slice(i + 1).split('*')[0]
// Split BEFORE unescaping: a literal comma inside a value is percent-encoded by escapeValue, so
// every `,` still here is a separator. Unescaping first would resurrect content commas and split
// the value in half.
const parts = body.split(LIST_SEP).map(unescapeValue).filter(Boolean)
if (key && parts.length) out.filters[key] = parts.length > 1 ? parts : parts[0]
}
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(LIST_SEP).map(unescapeValue).filter(Boolean)
if (parts.length) out.filters[busKey] = parts.length > 1 ? parts : parts[0]
}
return out
}
export function useShareableView(bus, options = {}) {
const { mode = 'hash', write: shouldWrite = true, facetKeys } = options
const push = () => {
try {
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 }), push, { deep: true })
onMounted(() => {
try {
if (typeof location === 'undefined') return
// 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 a bad URL */ }
})
}
export default useShareableView