From 2cd3b331416a371d804e2791c1cc904798a2361f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 18:29:19 +0300 Subject: [PATCH] fix(map): preserve empty facet selection --- apps/catalog/src/mapPresentationProfile.ts | 32 +++++++++++---- scripts/map-presentation-filters.test.mjs | 48 ++++++++++++++++++++-- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/apps/catalog/src/mapPresentationProfile.ts b/apps/catalog/src/mapPresentationProfile.ts index 4dff39b..d04c2f3 100644 --- a/apps/catalog/src/mapPresentationProfile.ts +++ b/apps/catalog/src/mapPresentationProfile.ts @@ -106,8 +106,9 @@ export function mapPresentationBindingIsAll(bindingId: string, filters: MapPrese /** * Apply one interactive facet-chip transition without collapsing the storage - * contract. A missing field means unconstrained, while an explicitly persisted - * empty array remains available to represent an intentional match-nothing view. + * contract. A missing field means that facet is not part of the current union, + * while one explicitly empty field represents the intentional match-nothing + * view after the final selected chip is switched off. */ export function toggleMapPresentationFacetSelection( facets: Record, @@ -116,14 +117,30 @@ export function toggleMapPresentationFacetSelection( ) { const selected = facets[field]; if (!selected?.includes(value)) { - return { ...facets, [field]: [...(selected ?? []), value] }; + // A saved empty view can contain one or more empty facet arrays. They are + // a global zero-match sentinel, not active constraints, so selecting the + // first chip must replace them instead of leaving a hidden empty array that + // would continue to suppress every fact. + const activeFacets = Object.fromEntries( + Object.entries(facets).filter(([, values]) => values.length > 0), + ); + return { ...activeFacets, [field]: [...(activeFacets[field] ?? []), value] }; } const nextSelected = selected.filter((item) => item !== value); if (nextSelected.length > 0) return { ...facets, [field]: nextSelected }; - const { [field]: _removed, ...unconstrained } = facets; - return unconstrained; + const { [field]: _removed, ...remaining } = facets; + const activeFacets = Object.fromEntries( + Object.entries(remaining).filter(([, values]) => values.length > 0), + ); + if (Object.keys(activeFacets).length > 0) return activeFacets; + + // Never collapse the last interactive deselection to `{}`: the renderer + // correctly interprets `{}` as unconstrained/all. Retain an explicit empty + // facet so the zero-selection state remains zero matches through any toggle + // cycle and through a later Application Save. + return { [field]: [] }; } export function mapPresentationProfileForFact( @@ -192,9 +209,8 @@ export function mapFactMatchesFilters( return [{ facet, selected }]; }); - // Persisted empty arrays are an explicit match-nothing state. Interactive - // deselection removes the field instead, so this branch is only reached for - // a deliberately saved empty view. + // Persisted empty arrays and the final interactive deselection are an + // explicit match-nothing state. if (selectedFacets.some(({ selected }) => selected.length === 0)) return false; if (selectedFacets.length === 0) return true; diff --git a/scripts/map-presentation-filters.test.mjs b/scripts/map-presentation-filters.test.mjs index b495dd0..cd4dbbb 100644 --- a/scripts/map-presentation-filters.test.mjs +++ b/scripts/map-presentation-filters.test.mjs @@ -49,13 +49,53 @@ test("deselecting the last chip remains empty and never normalizes to all", () = assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false); }); -test("interactive deselect of the last chip removes the facet constraint", () => { +test("interactive deselect of the last chip preserves the zero-match state", () => { const selected = toggleMapPresentationFacetSelection({}, "signal_state", "active"); assert.deepEqual(selected, { signal_state: ["active"] }); - const unconstrained = toggleMapPresentationFacetSelection(selected, "signal_state", "active"); - assert.deepEqual(unconstrained, {}); - assert.equal(mapPresentationBindingIsAll("fleet", { fleet: { visible: true, facets: unconstrained } }), true); + const empty = toggleMapPresentationFacetSelection(selected, "signal_state", "active"); + const filters = { fleet: { visible: true, facets: empty } }; + assert.deepEqual(empty, { signal_state: [] }); + assert.equal(mapPresentationBindingIsAll("fleet", filters), false); + assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false); +}); + +test("the first chip selected after a saved empty view removes every empty sentinel", () => { + const selected = toggleMapPresentationFacetSelection({ + signal_state: [], + movement_state: [], + }, "signal_state", "active"); + + assert.deepEqual(selected, { signal_state: ["active"] }); + assert.equal(mapFactMatchesFilters(onlineMoving, profile, { + fleet: { visible: true, facets: selected }, + }, "fleet"), true); +}); + +test("a complete interactive toggle cycle returns to zero matches instead of all", () => { + let facets = { signal_state: [], movement_state: [] }; + const toggle = (field, value) => { + facets = toggleMapPresentationFacetSelection(facets, field, value); + }; + + toggle("signal_state", "active"); + toggle("signal_state", "inactive"); + toggle("movement_state", "moving"); + toggle("movement_state", "stopped"); + assert.deepEqual(facets, { + signal_state: ["active", "inactive"], + movement_state: ["moving", "stopped"], + }); + + toggle("signal_state", "active"); + toggle("signal_state", "inactive"); + toggle("movement_state", "moving"); + toggle("movement_state", "stopped"); + + const filters = { fleet: { visible: true, facets } }; + assert.deepEqual(facets, { movement_state: [] }); + assert.equal(mapPresentationBindingIsAll("fleet", filters), false); + assert.equal(mapFactMatchesFilters(onlineMoving, profile, filters, "fleet"), false); }); test("interactive deselect preserves other values and facet constraints", () => {