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.
206 lines
12 KiB
JavaScript
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
|