fix(map): preserve empty facet selection
This commit is contained in:
@@ -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<string, string[]>,
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user