feat(filter-builder): the group header is the ALL/ANY switch

The group header ("MATCH"/"GROUP") was a dead label while the engine
honored group modes all along — buildGroupClause parenthesizes nested
groups and joins each by its own mode, and validation accepted
all/any from day one. The only thing missing was the UI: + Group
birthed ALL groups and no control anywhere could flip one, so the
user's target structures (GROUP(GROUP(... or ...) ...) — 3-level
mixed-mode nesting) were constructible only through the invisible
row-level chips, and standalone ANY-groups not at all (2026-11-01
user report: "MATCH cannot be clicked").

- The header is now the control: MATCH ALL ⇄ MATCH ANY (root — a
  synthetic header row, since the root was never projected and its
  mode was invisible too), GROUP ALL ⇄ GROUP ANY (nested). ANY is
  accented; aria-pressed carries the state. Labels are path-based
  (root reads Match, explicit groups read Group) instead of
  depth-based, which previously mislabeled top-level groups.
- The row-level chips are untouched — they mix AND/OR inside a group
  (A AND (B OR C)), which a global mode toggle cannot express; the
  header is the discoverable entry point, the chips the refinement.
- Dropdown audit (the "would it bug out" check): suggestTree is
  mode-generic — root-ANY drops group siblings, sibling ANY-groups
  survive into the constraint with their mode intact, empty groups
  never emit (no 400s) — now pinned. Deep-nesting Go pin: a 3-level
  mixed-mode tree evaluated through composed search + the suggestion
  endpoint (200, never the empty-group 400). 192 web tests green.
This commit is contained in:
John O'Keefe committed 2026-11-01 15:43:52 -05:00
1 parent 5fb80f82ed
commit 2344f5686a
7 files changed
+254 -5

No files matched your search

+94
View File
@@ -8,6 +8,7 @@ import {
opsForField,
pruneIncomplete,
ruleToFlatParam,
suggestTree,
type FilterGroupState,
type FilterRuleState,
} from "./filter_builder";
@@ -636,3 +637,96 @@ describe("init: embedded labels + form hydration", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── group ALL/ANY header toggle (2026-11-01): the header was a dead
// label while the engine honored group modes — now it's the control.
// The dropdown audit rides with it: suggestTree was already
// mode-generic; these pins lock that guarantee to the new shapes.
describe("group ALL/ANY header toggle", () => {
beforeEach(() => {
ingestFilterFields(builderFields);
});
it("flips a NESTED group's mode; the row chips re-derive", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [
{ field: "author", op: "contains", values: ["a"] },
{
mode: "all",
children: [
{ field: "author", op: "contains", values: ["b"] },
{ field: "author", op: "contains", values: ["c"] },
],
},
],
};
expect(c.groupMode([1])).toBe("all");
c.toggleGroupMode([1]);
expect(c.groupMode([1])).toBe("any");
expect((c.filterTree.children[1] as FilterGroupState).mode).toBe("any");
// the projection mirrors the mode: the second rule inside the
// any-group now joins with OR
const orRow = c.builderRows.find((r) => r.chip === "OR");
expect(orRow).toBeDefined();
});
it("flips the ROOT (path=[]) — the whole-query switch rides the wire", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [{ field: "author", op: "contains", values: ["a"] }],
};
expect(c.groupMode([])).toBe("all");
c.toggleGroupMode([]);
expect(c.filterTree.mode).toBe("any");
expect(c.treeJSON()).toContain('"mode":"any"');
c.toggleGroupMode([]);
expect(c.filterTree.mode).toBe("all");
});
});
describe("suggestTree under explicit groups (the group-toggle audit)", () => {
beforeEach(() => {
ingestFilterFields(builderFields);
});
it("root ANY: group siblings drop entirely (no constraint rides)", () => {
const tree: FilterGroupState = {
mode: "any",
children: [
{
mode: "all",
children: [{ field: "author", op: "contains", values: ["carroll"] }],
},
{ field: "library", op: "in", values: ["lib-1"] },
],
};
// editing the library rule: the author GROUP is an OR sibling — it
// must never narrow the menu
expect(suggestTree(tree, [1], "library")).toBeNull();
});
it("ALL root: a sibling explicit ANY-group survives with its mode intact", () => {
const tree: FilterGroupState = {
mode: "all",
children: [
{ field: "author", op: "contains", values: ["a"] },
{
mode: "any",
children: [
{ field: "author", op: "contains", values: ["carroll"] },
{ field: "author", op: "contains", values: ["orwell"] },
],
},
],
};
const t = suggestTree(tree, [0], "author");
expect(t).not.toBeNull();
expect(t!.mode).toBe("all");
const nested = t!.children[0] as FilterGroupState;
expect(nested.mode).toBe("any");
expect(nested.children).toHaveLength(2);
});
});
+36
View File
@@ -543,6 +543,21 @@ export function filterBuilderData() {
get builderRows(): BuilderRow[] {
const rows: BuilderRow[] = [];
// The ROOT projects as its own header row (2026-11-01): its mode
// was invisible before — no header, no control, permanently "all".
// path=[] distinguishes it from explicit groups (whose labels read
// "Group"); nodeAt([]) IS the root, so the header's toggle reaches
// it like any other group.
rows.push({
key: "root",
path: [],
type: "groupOpen",
depth: 0,
chip: "AND",
chipClickable: false,
isFirst: true,
group: this.filterTree as FilterGroupState,
});
projectRows(this.filterTree as FilterGroupState, [], 0, rows);
return rows;
},
@@ -617,6 +632,27 @@ export function filterBuilderData() {
}
return node;
},
// groupMode — a group's current all/any (root included: path=[] IS
// the root via nodeAt). Anything malformed reads as "all".
groupMode(path: number[]): "all" | "any" {
const node = this.nodeAt(path);
if (!node || isRule(node)) return "all";
return node.mode === "any" ? "any" : "all";
},
// toggleGroupMode — the group header's flip: ALL ⇄ ANY for that
// group only. The engine has always honored group modes
// (buildGroupClause parenthesizes nested groups), so this is pure
// UI exposure; the row-level chips and implicit OR-blocks are
// untouched (they mix AND/OR inside a group — a different, orthogonal
// feature).
toggleGroupMode(path: number[]): void {
const node = this.nodeAt(path);
if (!node || isRule(node)) return;
node.mode = node.mode === "any" ? "all" : "any";
this._touch();
},
addRule(path: number[]): void {
const parent = this.nodeAt(path);
if (!parent || !("children" in parent)) return;