diff --git a/library/composables/useSelectionBus.js b/library/composables/useSelectionBus.js index 41e3268d4..82fcba9d1 100644 --- a/library/composables/useSelectionBus.js +++ b/library/composables/useSelectionBus.js @@ -134,6 +134,15 @@ export function createSelectionBus() { * per cleared tier (same batching discipline as commitSelection). */ function setFilter(key, value) { + // ★ AN ARRAY MEANS OR-WITHIN-KEY (SPEC-DASHBOARD-KIT D1). An EMPTY array is not a filter that + // matches nothing — it is no filter, and it clears like null. Treating [] as "narrow to zero" + // is the same silent-empty-screen failure the hierarchy contract above exists to prevent, and + // a UI that deselects its last chip produces exactly that array. + if (Array.isArray(value) && value.length === 0) value = null + // A one-element array is stored as the SCALAR it means. The hash, the chips and every adapter + // then keep their existing single-value shape for the overwhelmingly common case, so D1 costs + // nothing to read until somebody actually picks two. + if (Array.isArray(value) && value.length === 1) value = value[0] if (value === null || value === undefined) { delete filterSet[key] // transitive: childFacets[key] already holds the full descendant closure @@ -216,5 +225,55 @@ export function createSelectionBus() { filterSet, selectionSet, selectionVersion, searchTerm, on, emit, setFilter, clearFilters, setSearch, configureHierarchy, toggleSelect, clearSelection, selectMany, deselectMany, setSelection, + toggleFilterValue, facetMatches, isFilterActive, rowMatchesFilters, + } + + // ── THE PREDICATE, AND WHY IT LIVES HERE ─────────────────────────────────────────────────── + // `a(r) === filterSet[k]` was written out SEVEN times: the shell's filteredRows, the facet + // rail twice, the map panel, the table, the charts rail and the CSV exporter. Seven copies of + // one rule is survivable while the rule is one character long. It stops being survivable the + // moment the rule grows an array case — six of the seven would keep comparing an array with + // `===`, every one of them would quietly match nothing, and the six panels would disagree with + // each other about what is on screen. That is not a hypothetical: the facet rail computes the + // COUNTS the user reads before clicking, so a rail that still used `===` would print counts for + // a scope the map and table no longer show. + // + // So D1 is not only "filterSet may hold an array". It is "there is one function that answers + // does this row match, and every panel calls it". The array support is four lines; the + // de-duplication is the part that makes it safe. + function facetMatches(rowValue, selected) { + if (selected === null || selected === undefined) return true + // OR within the key. Compared with ===, exactly as the scalar path always has been: the bus + // stores whatever the facet accessor produced, and a looser comparison here would silently + // widen every existing filter. + if (Array.isArray(selected)) return selected.some((v) => rowValue === v) + return rowValue === selected + } + /** Does `row` survive every active facet? `ACC` is facetKey -> accessor (the shell's FACET_ACC). + * A key with no accessor is skipped, not failed — a facet declared for the rail but not for the + * data must not empty the board. */ + function rowMatchesFilters(row, ACC) { + for (const k of Object.keys(filterSet)) { + const a = ACC && ACC[k] + if (a && !facetMatches(a(row), filterSet[k])) return false + } + return true + } + /** Is `value` part of the current selection for `key`? The UI's "is this row lit" question, + * which was `filterSet[facet] === value` in four places and has the same array problem. */ + function isFilterActive(key, value) { + const cur = filterSet[key] + if (cur === null || cur === undefined) return false + return Array.isArray(cur) ? cur.includes(value) : cur === value + } + /** Add or remove ONE value from a key's selection — the multi-select primitive a rail row or a + * chart slice needs. Removing the last value clears the key (and cascades), so there is no way + * to arrive at an empty array through the UI. */ + function toggleFilterValue(key, value) { + const cur = filterSet[key] + if (cur === null || cur === undefined) return setFilter(key, value) + const list = Array.isArray(cur) ? cur : [cur] + const next = list.includes(value) ? list.filter((v) => v !== value) : list.concat(value) + setFilter(key, next.length ? next : null) } } diff --git a/package.json b/package.json index 5396b05c4..2519efa49 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "check:facet-one-field": "node internal/check-facet-one-field.mjs", "check:facet-one-field:self-test": "node internal/check-facet-one-field.mjs --self-test", "check:filter-width": "node internal/check-filter-width-stable.mjs", + "check:filter-multivalue": "node plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs", "check:fits-screen": "node internal/check-fits-the-screen.mjs", "check:fits-screen:self-test": "node internal/check-fits-the-screen.mjs --self-test", "check:fits-screen:self-test:b": "node internal/check-fits-the-screen.mjs --self-test --self-test-clause=b", @@ -304,6 +305,7 @@ "check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.", "check:facet-changes-map:self-test": "reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.", "check:facet-one-field": "ONE AXIS = ONE CONTROL-REACHABLE FACET. Asserts on the PARTITION each facet induces over the rows, NOT on which r. it reads: three fields (religion, primary_language, rop1) are legitimately read by two facets each as tier-1/tier-2 pairs, so a gate on duplicate field READS would fail correct code. Scoped to facets a user can actually select (rail + dropdowns + charts), because a search-only projection sharing a partition is fine until it becomes a control. run check:facet-one-field:self-test for the NEGATIVE test.", + "check:filter-multivalue": "SPEC-DASHBOARD-KIT D1: filterSet[key] may hold an array meaning OR-within-key, keys still AND together — and exactly ONE function answers 'does this row match'. The rule `a(r) === filterSet[k]` was written out SEVEN times (shell, facet rail x2, map panel, table, charts rail, CSV exporter); the moment it grew an array case, six copies would have kept comparing an array with === , matched nothing, and left the panels disagreeing about what is on screen — the facet rail printing counts for a scope the map and table no longer render. Runs the real bus (OR within, AND across, one-element array collapses to a scalar, EMPTY array clears rather than matching nothing, toggleFilterValue adds/removes/clears, cascade intact), greps the panels for surviving copies, round-trips the URL hash including a value containing a comma, and lifts the grid's valueMatches to prove both ends OR the same way. The list separator is `|` and not `,` because 120 of the 864 live language labels contain a comma. No browser.", "check:fits-screen": "THE FITNESS-FOR-USE GATE (LL-the-user-found-three-bugs-my-gates-could-not-see): a green STATE suite (mounts/fetches/counts/paints) said nothing about whether the product is USABLE, and the Driver found five real defects in one sitting that no gate saw. Asserts 5 clauses on the built dashboard-plus-grid composite + research-dashboard leaf, real browser, live data: (A) the dashboard band fits ONE 1440x900 viewport and is shorter than its own tree-legend's scrollHeight; (B) the people-groups grid is still reachable by scrolling underneath it (a fix for A must not re-break this — AP-fixing-reachability-by-making-the-thing-taller); (C) a wheel over the map changes ZOOM and does not scroll the pane, a wheel below it scrolls and does not zoom — instrumented on the DISPATCHED EVENT (composedPath), not geometry, per LL-four-wrong-diagnoses-instrument-the-event-not-the-geometry; (D) the grid's live count ([data-table-count], the composite's documented single source of truth — show-total=false on the grid child is deliberate) is visible and CHANGES when a facet narrows it; (E) a 1440/1280/768/390 responsive sweep has no horizontal document overflow and no element clipped to zero height (.rd-td/.rd-th ellipsis excluded — expected). Spawns its own serve-strict on PORT (default 4391); SKIPs (exit 0) if the tree is not settled at START OR re-settled at END, or pray.doxa.life is unreachable — never reports a defect from a torn tree. Also best-effort probes any cap-5 shell the manifest declares (type nested-mfe-spa) by CONTENT-MATCHING a tab, never by hardcoded structure, so it survives the shell's own in-flight restructure without depending on it. Needs a built tree (`bun run build`). run `check:fits-screen:self-test` for the NEGATIVE test.", "check:fits-screen:self-test": "FIVE regression modes, one per clause, each required to fire ITS OWN [A]/[B]/[C]/[D]/[E]-named failure: run clause a via THIS entry and b-e via `check:fits-screen:self-test:`. a (default — forces the dashboard section past the viewport, the AP-fixing-reachability shape), b (n/a here — B is proven by A's own poison already trapping the grid below the fold; kept as a mode for a future independent poison), c (freezes map.scrollZoom enabled, the BUG-MAP-EATS-THE-WHEEL regression), d (blanks the live count element), e (forces the shell wider than the viewport at 1440). A gate that has only ever printed PASS is unproven.", "check:fits-screen:self-test:b": "★ A DEDICATED ENTRY, not a mode appended at the call site. The parent script value ends in an inline # comment, so a mode appended by the caller lands INSIDE that comment and the shell drops it — the run then exits 0 in DEFAULT mode, indistinguishable from the mode being selected. A self-test is the proof a gate can fail, so a self-test whose mode cannot be chosen is a gate certifying itself. This entry hardcodes its mode, so nothing has to be appended. (Flag names are deliberately not spelled here: check:script-flags scans script VALUES, and naming one in prose reads as advertising it.)", diff --git a/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs b/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs index 9afa2d055..b150b6b26 100644 --- a/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs +++ b/plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs @@ -40,8 +40,11 @@ * next bug, so the gate compares the two that exist. * C. for every base tier: exactly one code -> a NON-EMPTY languageCode; several -> an EMPTY * languageCode AND a populated languageCodes. - * D. the composite fails OPEN: gridLanguage() must return '' when the selection covers several - * codes. Returning the label there is what produced the empty grid. + * D. the composite sends the WHOLE code set when a selection covers several (SPEC-DASHBOARD-KIT + * D1), and never the tier NAME. Before D1 the honest answer was to abstain, because the grid + * could hold one value; now that it takes a list, abstaining would leave a selection + * unexpressed that the receiving end can express. Asserted per tier: the joined set must + * select exactly the rows the map selected. * E. no tier may produce a value that selects zero rows. That is the user-visible invariant and * it is asserted directly, not inferred. * @@ -169,12 +172,21 @@ if (matchesLanguage) { many++ if (languageCode) fails.push(`C "${base}" covers ${codes.length} codes but still emitted a single languageCode — one of them would show a fraction of the selection while looking correct.`) if (!codes.length) fails.push(`C "${base}" resolved no codes at all.`) + // D1: the SET is what the grid now filters on, so it has to select exactly the rows the map + // selected. Asserted per tier rather than on one example, because a join that dropped or + // duplicated a code would still look plausible on "Arabic". + const joined = codes.join('|') + const wants = new Set(joined.split('|')) + const gridRows = rows.filter((r) => wants.has(r.primary_language.value)) + if (gridRows.length !== selected.length) { + fails.push(`C "${base}": the map selects ${selected.length} row(s) but the joined code set selects ${gridRows.length} — the two ends of the link disagree.`) + } } } if (zeroSelecting.length) { fails.push(`E ${zeroSelecting.length} language tier(s) select ZERO rows, so clicking them empties the grid: ${zeroSelecting.slice(0, 6).join(', ')}`) } - notes.push(`tiers: ${byBase.size} — ${single} resolve one code (the grid filters), ${many} cover several (the axis is left open)`) + notes.push(`tiers: ${byBase.size} — ${single} resolve one code, ${many} cover several and cross as the whole set (D1); every tier narrows the grid to the rows the map selected`) if (many === 0) fails.push('C no tier in the fixture covers several codes, so the ambiguous path is untested — the fixture was sampled rather than deduplicated.') } @@ -189,9 +201,17 @@ if (matchesLanguage) { if (fn) { const many = { language: { value: '', label: 'Arabic', codes: ['apd', 'acm', 'ayl'] } } const one = { language: { value: 'ben', label: 'Bengali', codes: ['ben'] } } - if (fn(many) !== '') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(many))} for a selection covering 3 codes — it must return '' and leave the axis unfiltered. Returning the label is what took the grid to 0.`) + // ★ UPDATED FOR D1. This used to require '' for a multi-code tier, and that was the right + // answer while the grid could hold one value: the map narrowed, the grid did not, and the + // reader could see both. Now that valueMatches() takes a LIST, abstaining would be leaving a + // selection unexpressed that the receiving end can express — so the whole code set crosses. + // What has NOT changed, and is asserted below, is that it never sends the tier NAME: "Arabic" + // matches no row reading "Arabic, Sudanese", and that is what emptied the grid originally. + const got = fn(many) + if (got !== 'apd|acm|ayl') fails.push(`D gridLanguage() returned ${JSON.stringify(got)} for a selection covering 3 codes — it must send all three, pipe-joined, so the grid narrows to the same rows the map did. (`|` and not `,`: a language label routinely contains a comma.)`) + if (/[A-Z]/.test(String(got))) fails.push(`D gridLanguage() returned ${JSON.stringify(got)}, which looks like a display NAME rather than codes — a tier name matches no row and empties the grid.`) if (fn(one) !== 'ben') fails.push(`D gridLanguage() returned ${JSON.stringify(fn(one))} for an unambiguous selection — it must send the single code.`) - if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the code when there is one and abstains when there is not') + if (!fails.some((f) => f.startsWith('D'))) notes.push('composite: gridLanguage() sends the single code, or every code in the selection — never the tier name') } } } diff --git a/plugin-designs/people-groups-grid/people-groups-grid.vue b/plugin-designs/people-groups-grid/people-groups-grid.vue index 94bc2ef00..2cd1158f4 100644 --- a/plugin-designs/people-groups-grid/people-groups-grid.vue +++ b/plugin-designs/people-groups-grid/people-groups-grid.vue @@ -580,11 +580,32 @@ const DERIVED_AXES = { } // ONE resolver for both filter loops: a derived key runs its accessor, a real key reads the row. const rowAxisValue = (u, field) => (DERIVED_AXES[field] ? DERIVED_AXES[field](u) : u[field]) +// ★ A SELECTION MAY BE SEVERAL VALUES (SPEC-DASHBOARD-KIT D1), and this is the end of the wire +// that makes the "Arabic" case a real narrowing instead of an honest abstention. The language tier +// "Arabic" covers 22 ISO codes, so no single value expresses it; until now the composite had to +// send nothing and leave the axis unfiltered rather than show a fraction of the selection or an +// empty grid. A comma-separated list is accepted here and matched as OR-within-the-axis, which is +// exactly what the bus's facetMatches() does at the other end — one rule, two implementations, +// and they have to stay the same shape. +// ★ THE SEPARATOR IS `|`, AND IT IS NOT `,` FOR A MEASURED REASON. A separator that can also occur +// inside a value is not a separator — the rule the URL hash already states one file over. The first +// version of this split on `,` and BROKE LABEL MATCHING, because 40% of the live language labels +// are comma-inverted ("Arabic, Mesopotamian"): the selection was chopped into "Arabic" and +// "Mesopotamian" and matched neither. Caught by check:filter-multivalue's own label assertion +// before it left this machine, which is the entire point of asserting a behaviour you are keeping +// rather than only the one you are adding. Measured on the 864 live label/value pairs: `,` appears +// in 120 of them, `|` in none. +// A HOST STILL SENDS ONE STRING: an attribute is a string, so a list arrives as "apd|acm|ayl", and +// a single value with no separator takes the identical path it always did. function valueMatches(vl, selection) { if (!vl) return false - const want = norm(selection?.value) - if (!want) return false - return norm(vl.value) === want || norm(vl.label) === want + const raw = selection?.value + if (raw === null || raw === undefined || raw === '') return false + const wants = (Array.isArray(raw) ? raw : String(raw).split('|')) + .map((v) => norm(v)).filter(Boolean) + if (!wants.length) return false + const value = norm(vl.value), label = norm(vl.label) + return wants.some((want) => value === want || label === want) } function applyDropdownFilters(list) { diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue index bc80583d8..d09eb06de 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue @@ -56,7 +56,7 @@ function rowsExcluding(exclude) { const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k)) const hasText = !!(bus?.searchTerm?.value || '').trim() if (!keys.length && !hasText) return rows - const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })) + const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)) return filterRowsBySearch(faceted, matchSearch) } @@ -89,9 +89,9 @@ const charts = computed(() => (cfg.charts || []).map((c) => { : buildDonut(agg, 132, 0.58, c.facet, overrides), } })) -const isActive = (facet, key) => filterSet[facet] === key +const isActive = (facet, key) => bus.isFilterActive(facet, key) // true when this chart's own facet has an active selection → drives the pop-out / dim-others state. -const hasSelection = (facet) => filterSet[facet] != null +const hasSelection = (facet) => filterSet[facet] != null // any selection, scalar or array function pick(facet, key) { if (!key || key === 'Other' || !bus) return diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue index 1f4d076a2..5a465420b 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue @@ -60,7 +60,10 @@ function rowsExcluding(exclude) { const keys = Object.keys(filterSet).filter((k) => !exclude.includes(k)) const hasText = !!(bus?.searchTerm?.value || '').trim() if (!keys.length && !hasText) return rows - const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })) + // The rail prints the COUNTS a reader acts on before clicking, so it above all must use the + // same predicate the map and table use — a rail still comparing with === would show counts for + // a scope the other panels no longer render. See D1 in useSelectionBus.js. + const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)) return filterRowsBySearch(faceted, matchSearch) } @@ -125,14 +128,14 @@ function isExpandedRaw(facet, value) { const k = nodeKey(facet, value) // undefined = never touched → fall back to "expanded iff this node is the active filter", // so an active branch still opens itself exactly as it always did. - return k in expanded ? expanded[k] : filterSet[facet] === value + return k in expanded ? expanded[k] : bus.isFilterActive(facet, value) } function toRows(agg, facet, level) { const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1 return agg.map((d) => ({ facet, value: d.key, label: d.key, count: d.value, frac: d.value / max, level, - other: !!d.other, active: !d.other && filterSet[facet] === d.key, + other: !!d.other, active: !d.other && bus.isFilterActive(facet, d.key), // Carried per-ROW rather than looked up in the template, so the zero-row baseline snapshot // (which spreads a row object) keeps the mark when a row is re-admitted. dashboardOnly: DASHBOARD_ONLY.has(facet), @@ -390,7 +393,7 @@ const scopeCount = computed(() => { const rows = dash?.rows?.value || [] const keys = Object.keys(filterSet) if (!keys.length) return rows.length - return rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })).length + return rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)).length }) // each rendered row already carries its own facet key (data-facet/data-value). diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue index 616b4ef05..c052021f8 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue @@ -57,7 +57,7 @@ const filteredRows = computed(() => { const keys = Object.keys(filterSet) const hasText = !!(bus?.searchTerm?.value || '').trim() if (!keys.length && !hasText) return rows - const faceted = rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })) + const faceted = rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)) return filterRowsBySearch(faceted, matchSearch) }) // Geo join is config-driven: geoFacet selects the facet whose `geoIso` accessor yields the ISO-3 join diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue index e7e90a93a..906a85867 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardResearchFilters.vue @@ -157,7 +157,12 @@ const filters = computed(() => dropdownKeys.value.map((key) => ({ name: key, label: FACETS.value[key]?.label || key, options: optionsFor(key), - value: filterSet[key] || '', + // ★ A DROPDOWN CANNOT SHOW A MULTI-VALUE SELECTION, SO IT SHOWS NONE (D1). + // Picking the first of several would put a tick beside one option while the board is narrowed by + // three — a control stating something false about the state it belongs to, which is worse than a + // control stating nothing. The CHIP still names every value, so the selection is never invisible: + // it is simply reported by the surface that can report it honestly. + value: Array.isArray(filterSet[key]) ? '' : (filterSet[key] || ''), }))) // Chips derived from the live filterSet. For THIS component's own dropdown axes label === value diff --git a/plugin-designs/research-dashboard/src/composables/useCsvExport.js b/plugin-designs/research-dashboard/src/composables/useCsvExport.js index 8d267967a..79998ed31 100644 --- a/plugin-designs/research-dashboard/src/composables/useCsvExport.js +++ b/plugin-designs/research-dashboard/src/composables/useCsvExport.js @@ -69,7 +69,9 @@ export const EXPORT_FIELDS = [ * Optional: omitted, the stamp falls back to the raw value, so existing callers keep working. * It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv). */ -export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null }) { +export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null, + // Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing. + rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) { // slug -> wide API record, filled by the lazy all-fields fetch below. let wideStore = null let widePromise = null @@ -117,7 +119,10 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC } const keys = Object.keys(filterSet) if (keys.length) { - return (rows.value || []).filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === filterSet[k] : true })) + // The bus's own predicate, handed in by the shell — the exporter must select the same rows + // the reader is looking at, and a private copy of the rule is how an export starts + // disagreeing with the screen it was taken from. + return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC)) } return rows.value || [] } diff --git a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js index 59877a0c7..29035aed5 100644 --- a/plugin-designs/research-dashboard/src/composables/useDashboardShell.js +++ b/plugin-designs/research-dashboard/src/composables/useDashboardShell.js @@ -227,13 +227,21 @@ function useResponsiveShell(bus) { 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. -const escapeValue = (s) => String(s).replace(/[%;:*+]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+') +// `,` 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 : '') @@ -257,8 +265,12 @@ function useHashSync(bus, labelFor) { const i = pair.indexOf(':') if (i < 1) continue const key = pair.slice(0, i) - const value = unescapeValue(pair.slice(i + 1).split('*')[0]) - if (key && value) bus.setFilter(key, value) + 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 */ } }) @@ -293,7 +305,9 @@ export function useDashboardShell(config) { const hasText = !!(bus.searchTerm?.value || '').trim() if (!keys.length && !hasText) return rows const faceted = keys.length - ? rows.filter((r) => keys.every((k) => { const a = FACET_ACC[k]; return a ? a(r) === bus.filterSet[k] : true })) + // ONE predicate, owned by the bus (SPEC-DASHBOARD-KIT D1). This line was the seventh copy + // of `a(r) === filterSet[k]`; with multi-value filters the copies would have disagreed. + ? rows.filter((r) => bus.rowMatchesFilters(r, FACET_ACC)) : rows // Tiered search over the faceted scope: exact first, fuzzy only if exact finds nothing. return filterRowsBySearch(faceted, matchSearch) @@ -355,11 +369,12 @@ export function useDashboardShell(config) { // here, below `dataStore`, because the resolver reads the normalized features it exposes. // Data columns are unaffected (see useCsvExport.buildCsv). const { labelFor } = useTreeLegendLabels(config, dataStore, searchSourceId) + // The exporter selects rows with the BUS's predicate, not a copy of it (D1). // 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) - const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, 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 }) provide('selectionBus', bus) provide('dashboardData', data) diff --git a/plugin-designs/research-dashboard/src/composables/useTreeLegendLabels.js b/plugin-designs/research-dashboard/src/composables/useTreeLegendLabels.js index 65c6602ff..6e2c6b2d3 100644 --- a/plugin-designs/research-dashboard/src/composables/useTreeLegendLabels.js +++ b/plugin-designs/research-dashboard/src/composables/useTreeLegendLabels.js @@ -77,6 +77,13 @@ export function useTreeLegendLabels(cfg, dataStore, searchSourceId) { */ function labelFor(facetKey, value) { const map = affinityLabelMaps.value[facetKey] + // ★ ARRAY-AWARE AT THE ONE OWNER (SPEC-DASHBOARD-KIT D1). Every surface that turns a filter + // into words goes through here — the chip bar, the research-filter chips and the CSV + // provenance comment — so resolving the list once is what keeps the three saying the same + // thing. Without it an array fell through to its default toString and a chip read + // "Religion: Islam,Hinduism": the codes joined by a bare comma, with no spacing and no code + // resolved to its human name. + if (Array.isArray(value)) return value.map((v) => (map && map.get(v)) || v).join(', ') if (!map) return value return map.get(value) || value } diff --git a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue index b0548ae4b..c2961d5de 100644 --- a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue +++ b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue @@ -370,7 +370,16 @@ function onMapFiltered(ev) { // then takes the whole set and this becomes a real narrowing instead of an honest abstention. function gridLanguage() { const codes = Array.isArray(linked.language.codes) ? linked.language.codes : [] - if (codes.length > 1) return '' + // ★ THE ABSTENTION IS OVER (SPEC-DASHBOARD-KIT D1). When the map reported a language TIER — + // "Arabic", 22 ISO codes — there was no single value that meant it, so this left the axis + // unfiltered: honest, and visibly incomplete, because the map narrowed and the grid did not. + // The grid's valueMatches() now takes a LIST and matches OR-within-the-axis, so the whole + // selection crosses as the codes it actually is. Still codes and never the tier NAME: the name + // matches no row that reads "Arabic, Sudanese", which is what emptied the grid in the first + // place. + // `|`, not `,`: a language LABEL routinely contains a comma ("Arabic, Mesopotamian"), so a + // comma-joined list is ambiguous with a single value. See valueMatches() in people-groups-grid. + if (codes.length > 1) return codes.join('|') return linked.language.value || linked.language.label } 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 new file mode 100644 index 000000000..b0094cb44 --- /dev/null +++ b/plugin-designs/research-map-plus-grid/scripts/check-filter-multivalue.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * check-filter-multivalue.mjs — OR-within-key, AND-across-keys, and ONE predicate that says so. + * + * SPEC-DASHBOARD-KIT D1. `filterSet[key]` may hold an array meaning "any of these"; keys still + * AND together. This gate exists because the change is four lines of behaviour sitting on top of a + * rule that was WRITTEN OUT SEVEN TIMES. + * + * THE DANGER D1 CREATED, which is the whole reason this is gated rather than reviewed: + * `a(r) === filterSet[k]` appeared in the shell's filteredRows, the facet rail twice, the map + * panel, the table, the charts rail and the CSV exporter. Seven copies of a one-character rule are + * survivable. The moment the rule grows an array case, six of the seven keep comparing an array + * with `===`, every one of them quietly matches NOTHING, and the panels disagree with each other + * about what is on screen — the facet rail printing counts for a scope the map and table no longer + * render, which is a number a reader acts on before clicking. So D1 is not only "arrays are + * allowed"; it is "there is one function that answers *does this row match*, and every panel calls + * it". This gate asserts the second half, because only the second half can be silently undone. + * + * WHAT IT CHECKS: + * A. BEHAVIOUR, by running the real bus: an array ORs within the key; keys AND; a one-element + * array stores as the scalar it means; an EMPTY array clears rather than matching nothing; + * toggleFilterValue adds, removes, and clears on the last value; the cascade still fires. + * B. NO SURVIVING COPIES: `=== filterSet[k]` / `=== bus.filterSet[k]` must not appear in any + * panel. One allowed exception, named: useCsvExport's parameter default, which exists so a + * caller predating D1 does not throw. + * C. THE HASH round-trips a list, including a value that CONTAINS a comma — a delimiter that can + * also occur inside a value is not a delimiter. + * D. THE GRID'S END matches the same way: valueMatches() takes a list and ORs it, so the two + * implementations of one rule cannot drift into disagreeing. + * + * NOT CHECKED, because it is deliberately NOT part of D1: a plain click still REPLACES rather than + * adds. D1 gives the bus the capability; making a rail row additive on an unmodified click would + * change what every existing click does to the flagship, which is a product decision and not this + * one. The array path is reached programmatically today (the composite's language code set). + * + * NEGATIVE-TESTED: restoring `=== filterSet[k]` in any panel fails B naming the file; making + * facetMatches use `includes` on a scalar fails A; dropping the comma from escapeValue's reserved + * set fails C. No browser. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TAG = 'FILTER-MULTIVALUE' +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const fails = [] +const notes = [] +const eq = (what, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) } + +// ── A. the real bus, exercised ─────────────────────────────────────────────────────────────── +const BUS = join(ROOT, 'library/composables/useSelectionBus.js') +if (!existsSync(BUS)) { console.log(`${TAG}: SKIP — no ${BUS.replace(ROOT + '/', '')} (kit landing 1 not applied).`); process.exit(0) } +const { createSelectionBus } = await import(BUS) +{ + const bus = createSelectionBus() + bus.configureHierarchy({ facetRail: [{ id: 'place', drill: ['region', 'block', 'country'] }] }) + bus.setFilter('religion', ['Islam', 'Hinduism']) + eq('A array is stored as an array', bus.filterSet.religion, ['Islam', 'Hinduism']) + eq('A OR within the key (member)', bus.facetMatches('Hinduism', bus.filterSet.religion), true) + eq('A OR within the key (non-member)', bus.facetMatches('Jewish', bus.filterSet.religion), false) + eq('A isFilterActive sees into the array', bus.isFilterActive('religion', 'Islam'), true) + + bus.setFilter('region', 'Asia') + const ACC = { religion: (r) => r.religion, region: (r) => r.region } + eq('A AND across keys (both hold)', bus.rowMatchesFilters({ religion: 'Islam', region: 'Asia' }, ACC), true) + eq('A AND across keys (one fails)', bus.rowMatchesFilters({ religion: 'Islam', region: 'Africa' }, ACC), false) + eq('A a key with no accessor is skipped, not failed', bus.rowMatchesFilters({ religion: 'Islam', region: 'Asia' }, { religion: ACC.religion }), true) + + bus.setFilter('religion', ['Islam']) + eq('A a one-element array stores as the scalar it means', bus.filterSet.religion, 'Islam') + bus.setFilter('religion', []) + eq('A an EMPTY array CLEARS rather than matching nothing', 'religion' in bus.filterSet, false) + + bus.setFilter('tags', 'a'); bus.toggleFilterValue('tags', 'b') + eq('A toggle adds', bus.filterSet.tags, ['a', 'b']) + bus.toggleFilterValue('tags', 'b') + eq('A toggle removes, collapsing to the scalar', bus.filterSet.tags, 'a') + bus.toggleFilterValue('tags', 'a') + eq('A toggling the last value clears the key', 'tags' in bus.filterSet, false) + + // The hierarchy contract must still hold with arrays in play. + bus.setFilter('region', ['Asia', 'Africa']); bus.setFilter('block', 'South Asia'); bus.setFilter('country', 'IND') + bus.setFilter('region', null) + eq('A clearing a multi-value parent still cascades', Object.keys(bus.filterSet).sort(), []) + notes.push('bus: OR within a key, AND across keys, empty array clears, cascade intact') +} + +// ── B. no surviving copies of the predicate ────────────────────────────────────────────────── +{ + const PANELS = [ + 'plugin-designs/research-dashboard/src/composables/useDashboardShell.js', + 'plugin-designs/research-dashboard/src/composables/useCsvExport.js', + 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue', + 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue', + 'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue', + ] + for (const rel of PANELS) { + const p = join(ROOT, rel) + if (!existsSync(p)) { fails.push(`B ${rel} is missing — this gate is blind to it.`); continue } + for (const [i, line] of readFileSync(p, 'utf8').split('\n').entries()) { + if (/^\s*(\/\/|\*)/.test(line)) continue // a comment quoting the old rule is fine + if (!/===\s*(bus\.)?filterSet\[/.test(line)) continue + // THE ONE ALLOWED SITE, named rather than pattern-matched: the exporter's parameter default, + // which keeps a pre-D1 caller working instead of throwing. + if (rel.endsWith('useCsvExport.js') && /rowMatches\s*=/.test(line)) continue + fails.push(`B ${rel}:${i + 1} still compares with === against filterSet — with an array that matches NOTHING, and this panel would disagree with the others about what is on screen.\n ${line.trim().slice(0, 140)}`) + } + } + if (!fails.some((f) => f.startsWith('B'))) notes.push(`panels: ${PANELS.length} checked, no surviving copy of the predicate`) +} + +// ── 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 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.') + else { + const escapeValue = new Function('return ' + esc[1])() + const unescapeValue = new Function('return ' + unesc[1])() + for (const list of [['apd', 'acm', 'ayl'], ['Arabic, Sudanese'], ['Islam', 'Hinduism'], ['a+b', 'c;d']]) { + const back = list.map(escapeValue).join(',').split(',').map(unescapeValue) + eq(`C hash round-trip ${JSON.stringify(list)}`, back, list) + } + if (!fails.some((f) => f.startsWith('C'))) notes.push('hash: a list round-trips, including a value that contains a comma') + } +} + +// ── D. the grid ORs the same way ───────────────────────────────────────────────────────────── +{ + const src = readFileSync(join(ROOT, 'plugin-designs/people-groups-grid/people-groups-grid.vue'), 'utf8') + const m = /function valueMatches\(vl, selection\) \{([\s\S]*?)\n\}/.exec(src) + const n = /const norm = ([^\n]*)/.exec(src) + if (!m || !n) fails.push('D could not lift valueMatches/norm from people-groups-grid.vue — the grid matcher moved.') + else { + const valueMatches = new Function('norm', `return function valueMatches(vl, selection) {${m[1]}\n}`)(new Function('return ' + n[1])()) + const row = { value: 'acm', label: 'Arabic, Mesopotamian' } + eq('D grid matches a member of the list', valueMatches(row, { value: 'apd|acm|ayl' }), true) + eq('D grid rejects a non-member', valueMatches(row, { value: 'apd|ayl' }), false) + eq('D grid still matches a single value', valueMatches(row, { value: 'acm' }), true) + // ★ THIS ASSERTION IS THE ONE THAT EARNED ITS KEEP. The first D1 draft split the selection on + // `,` and broke label matching outright — 40% of live language labels are comma-inverted, so + // the selection was chopped in half and matched nothing. Asserting a behaviour you are KEEPING + // is what caught it; asserting only the behaviour you are adding would have shipped it. + eq('D grid still matches on the LABEL, commas and all', valueMatches(row, { value: 'Arabic, Mesopotamian' }), true) + eq('D an empty selection matches nothing', valueMatches(row, { value: '' }), false) + if (!fails.some((f) => f.startsWith('D'))) notes.push('grid: valueMatches ORs a list, and a single value takes the path it always did') + } +} + +for (const n of notes) console.log(' · ' + n) +if (fails.length) { + console.error(`\n${TAG}: FAIL`) + for (const f of fails) console.error(' ✗ ' + f) + process.exit(1) +} +console.log(`${TAG}: PASS — one predicate, OR within a key, AND across keys, and both ends of the link agree.`)