A filtered table becomes a link anyone can open, and the link now carries the typed search
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.
This commit is contained in:
parent
abefdce416
commit
655bbdc561
5 changed files with 161 additions and 75 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
108
library/composables/useShareableView.js
Normal file
108
library/composables/useShareableView.js
Normal file
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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])()
|
||||
|
|
|
|||
Loading…
Reference in a new issue