diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index ec497ef9b..11f4d6108 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -191,6 +191,8 @@ and a change here can break other maps. `import … from '@map/composables/useSelectionCamera.js'` - **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection `import … from '@map/composables/useShadowStyles.js'` +- **useShareableView** — useShareableView — a dashboard's view state in the address bar, so a narrowed table can be SENT. + `import … from '@map/composables/useShareableView.js'` - **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it. `import … from '@map/composables/useSharedMapToken.js'` - **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in diff --git a/internal/check-share-policy.mjs b/internal/check-share-policy.mjs index 50563b45e..f2e715082 100644 --- a/internal/check-share-policy.mjs +++ b/internal/check-share-policy.mjs @@ -86,6 +86,7 @@ const manifest = JSON.parse(readFileSync(join(REPO, 'public', TREE_NAME, 'manife const published = new Set(manifest.bundles.map((b) => b.bundle)) const fails = [] +const notes = [] const rows = [] for (const name of readdirSync(DESIGNS)) { const dir = join(DESIGNS, name) @@ -136,6 +137,48 @@ if (debt.length) { + debt.map((d) => d.name).join(', ')) } +// ── THE SHARE LINK CARRIES THE WHOLE VIEW, INCLUDING THE TYPED SEARCH ───────────────────────── +// The hash carried filterSet only, so a table narrowed by TYPING reopened with MORE ROWS than the +// sender saw — 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. +// DRIVEN, not asserted: the two pure functions are lifted from the kit source and RUN, so the +// guarantee is measured. Proving a string round-trips needs no browser. +{ + const SRC = 'library/composables/useShareableView.js' + let src = '' + try { src = readFileSync(resolve(REPO, SRC), 'utf8') } catch (_) { src = '' } + 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 + try { + ;({ serializeView, parseView } = new Function(`${pure.replace(/export /g, '')}; return { serializeView, parseView }`)()) + } 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.`) + } + if (serializeView && parseView) { + const L = (k) => ({ affinity_bloc: 'Malay Peoples' }[k]) + const eq = (what, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`SHARE-VIEW ${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) } + // An empty search box must write the hash the old code wrote, byte for byte, so every link + // already sitting in a teammate's notes keeps working and keeps looking the same. + eq('an empty search still writes the old format', serializeView({ religion_family: 'Hinduism' }, '', { labelFor: L }), '#f=religion_family:Hinduism') + eq('the slug stays display-only', serializeView({ affinity_bloc: 'A008' }, '', { labelFor: L }), '#f=affinity_bloc:A008*malay-peoples') + eq('a multi-value key still drops the slug', serializeView({ religion_family: ['Hinduism', 'Islam'] }, ''), '#f=religion_family:Hinduism,Islam') + // The defect this arm exists for. + eq('the typed search reaches the link', serializeView({}, 'bhutan'), '#f=&q=bhutan') + eq('and comes back', parseView(serializeView({ religion_family: 'Hinduism' }, 'bhutan', { labelFor: L })), { filters: { religion_family: 'Hinduism' }, search: 'bhutan' }) + // Delimiters that occur inside real values: a people group with a comma, a search with an `&`. + eq('a literal comma in a value survives', parseView(serializeView({ language: ['Arabic, Sudanese'] }, '')).filters, { language: 'Arabic, Sudanese' }) + eq('an ampersand in the search survives', parseView(serializeView({}, 'rice & beans')).search, 'rice & beans') + // 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') + } + } +} +for (const n of notes) console.log(' · ' + n) + if (fails.length) { console.error(`\nSHARE-POLICY: FAIL — ${fails.length} problem(s)`) for (const f of fails) console.error(' - ' + f) diff --git a/library/composables/useShareableView.js b/library/composables/useShareableView.js new file mode 100644 index 000000000..973484d40 --- /dev/null +++ b/library/composables/useShareableView.js @@ -0,0 +1,108 @@ +/** + * 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 diff --git a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js index f1fce9e7d..7664d8636 100644 --- a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js +++ b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js @@ -28,6 +28,7 @@ import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } fro import { createSelectionBus } from '@map/composables/useSelectionBus.js' import { useDashboardData } from '@map/composables/useDashboardData.js' import { useCsvExport } from '@map/composables/useCsvExport.js' +import { useShareableView } from '@map/composables/useShareableView.js' import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js' import { formatCount } from '@map/utils/formatCount.js' import { useTreeLegendLabels } from './useTreeLegendLabels.js' @@ -137,78 +138,10 @@ function useResponsiveShell(bus) { } } -/** - * Shareable view-state hash URL: serialize filterSet to location.hash; restore on mount. - * - * ★ READABLE, CODE-CANONICAL FORMAT (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. A URL is the one part of the - * product a user COPIES, PASTES and READS, and for sensitive population data a link you cannot - * sanity-check before clicking is a real cost. The format is now - * - * #f=affinity_bloc:A008*malay-peoples;religion_family:Hinduism - * - * -- the Stack Overflow /questions/123/slug pattern: the CODE is canonical and the *slug is - * DISPLAY-ONLY, appended from labelFor() at share time and STRIPPED on parse. Restoring never - * reads the slug, so links stay locale-independent and survive label renames (the card's - * explicit constraint: keep the code canonical, do not swap in localized labels). - * - * `*` is safe as the slug separator because escapeValue() percent-encodes any literal `*` in - * the VALUE first -- the separator cannot collide with content. `;` and `:` are legal fragment - * chars, so the whole thing stays legible in the address bar. Legacy JSON hashes (old shared - * links) are detected by their leading `{` / %7B and still restore. - */ -const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) -// Minimal escaping, maximal legibility: reserve our three delimiters + %, keep spaces as `+` -// (the x-www-form-urlencoded convention), leave everything else -- including unicode -- alone. -// `,` joins the values of ONE key (D1: OR-within-key). It is reserved here for the same reason -// `;` `:` and `*` already are — a delimiter that can also occur inside a value is not a -// delimiter — so a literal comma in a facet value is percent-encoded before the join. -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, ' ') } } - -function useHashSync(bus, labelFor) { - function syncHash() { - try { - const pairs = Object.entries(bus.filterSet).map(([k, v]) => { - // A MULTI-VALUE KEY KEEPS THE CODE CANONICAL AND DROPS THE SLUG. The `*slug` is - // display-only and singular by construction — one human name for one code — so there is no - // honest slug for "these twenty-two codes". Writing one would put a label in a URL that - // does not describe what the URL selects, and the slug is already stripped on parse. - 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 : '') - }) - if (typeof location !== 'undefined') history.replaceState(null, '', '#f=' + pairs.join(';')) - } catch (_) { /* sandboxed */ } - } - watch(() => ({ ...bus.filterSet }), syncHash, { deep: true }) - onMounted(() => { - try { - const m = typeof location !== 'undefined' && /[#&]f=([^&]+)/.exec(location.hash) - if (!m) return - const raw = m[1] - if (raw.startsWith('{') || raw.startsWith('%7B') || raw.startsWith('%7b')) { - // legacy JSON hash from an old shared link - const obj = JSON.parse(decodeURIComponent(raw)) - Object.entries(obj).forEach(([k, v]) => bus.setFilter(k, v)) - return - } - for (const pair of raw.split(';')) { - const i = pair.indexOf(':') - if (i < 1) continue - const key = pair.slice(0, i) - const raw = pair.slice(i + 1).split('*')[0] - // Split BEFORE unescaping: a literal comma inside a value is percent-encoded by - // escapeValue, so every `,` still present here is a separator. Unescaping first would - // resurrect the content commas and split the value in half. - const parts = raw.split(',').map(unescapeValue).filter(Boolean) - if (key && parts.length) bus.setFilter(key, parts.length > 1 ? parts : parts[0]) - } - } catch (_) { /* ignore bad hash */ } - }) -} +// The shareable view-state hash moved to library/composables/useShareableView.js (persona card +// BUILD-SHAREABLE-FILTERED-TABLES). It was private to this file, so exactly one dashboard in the fleet +// could produce a share link; nothing in it was research-specific except its address. This dashboard +// supplies the one thing that is: labelFor, for the display-only *slug. export function useDashboardShell(config) { // One brushing-and-linking bus, provided to every panel (publisher + subscriber). @@ -307,7 +240,7 @@ export function useDashboardShell(config) { // shareable view-state hash URL -- below labelFor on purpose: the hash's display-only slugs // resolve through the SAME code->name resolver the chips and the CSV provenance line use. - useHashSync(bus, labelFor) + useShareableView(bus, { labelFor }) const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor, rowMatches: bus.rowMatchesFilters }) // ★ THE RESEARCH LEGEND, HANDED TO THE PANELS AS DATA (kit landing 4b). 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 56fecba31..4a5e2419a 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 @@ -114,10 +114,10 @@ const { createSelectionBus } = await import(BUS) // ── C. the hash round-trips a list, commas and all ─────────────────────────────────────────── { - const src = readFileSync(join(ROOT, 'plugin-designs/research-dashboard/src/composables/useDashboardShell.js'), 'utf8') + const src = readFileSync(join(ROOT, 'library/composables/useShareableView.js'), 'utf8') const esc = /const escapeValue = (\(s\) =>[^\n]*)/.exec(src) const unesc = /const unescapeValue = (\(s\) =>[^\n]*)/.exec(src) - if (!esc || !unesc) fails.push('C could not lift escapeValue/unescapeValue from useDashboardShell.js — the hash format moved and this gate is blind to it.') + if (!esc || !unesc) fails.push('C could not lift escapeValue/unescapeValue from useShareableView.js — the hash format moved and this gate is blind to it.') else { const escapeValue = new Function('return ' + esc[1])() const unescapeValue = new Function('return ' + unesc[1])()