Card BUILD-SHAREABLE-FILTERED-TABLES, for researchers and media-to-movements teams: narrow the table to
the peoples you are working on, then give that exact view to a teammate or reopen it on a phone.
TWO GAPS, both measured on main before building.
1. IT WAS NOT REUSABLE. The share hash was a PRIVATE function inside
plugin-designs/research-dashboard/src/composables/useDashboardShell.js, so exactly one dashboard in
the fleet could produce a link. go-int's board had none — grepped, zero matches — and every future
kit dashboard would have had none either. Nothing in it was research-specific except its address.
It is now library/composables/useShareableView.js, parameterized: the bus and labelFor are passed
in, the filter and search param names are options, and no dashboard is named inside it. The
research dashboard supplies the one genuinely research thing — labelFor, for the display-only slug.
2. THE LINK WAS INCOMPLETE, AND SILENTLY SO. The hash serialized 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 saw, with nothing to tell the recipient 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 writes byte-identically the hash it wrote before.
`&` joins params now, so it is reserved in values alongside `;` `:` `*` `,` `%` — otherwise a search for
"rice & beans" would truncate the link at the ampersand.
check:share-policy grows a DRIVEN arm: the two pure functions are lifted from the kit source and RUN,
so nine guarantees are measured rather than asserted — the old format byte-for-byte on an empty search,
slug still display-only, multi-value still dropping the slug, the typed search reaching the link and
coming back, a literal comma in a people-group name surviving, an ampersand in the search surviving, and
both legacy shapes (JSON hashes, and old #f= links with no q) still restoring. No browser needed to
prove a string round-trips.
Source only. Rebuild: research-dashboard, dashboard-plus-grid.
check:filter-multivalue arm C follows the file: it lifted escapeValue/unescapeValue from the shell to
drive the hash round-trip, and said so plainly when they moved ("the hash format moved and this gate is
blind to it") rather than passing vacuously. Repointed to the kit module.
108 lines
6.5 KiB
JavaScript
108 lines
6.5 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
|
|
*/
|
|
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.
|
|
const escapeValue = (s) => String(s).replace(/[%;:*+,&]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
|
|
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(',').map(unescapeValue).filter(Boolean)
|
|
if (key && parts.length) out.filters[key] = parts.length > 1 ? parts : parts[0]
|
|
}
|
|
return out
|
|
}
|
|
|
|
export function useShareableView(bus, options = {}) {
|
|
const write = () => {
|
|
try {
|
|
if (typeof location === 'undefined') return
|
|
history.replaceState(null, '', serializeView(bus.filterSet, bus.searchTerm?.value, options))
|
|
} catch (_) { /* sandboxed */ }
|
|
}
|
|
watch(() => ({ ...bus.filterSet, __q: bus.searchTerm?.value }), write, { deep: true })
|
|
onMounted(() => {
|
|
try {
|
|
if (typeof location === 'undefined') return
|
|
const { filters, search } = 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 */ }
|
|
})
|
|
}
|
|
|
|
export default useShareableView
|