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:
1 parent
5fb80f82ed
commit
2344f5686a
7 files changed
+254
-5
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user