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

+11 -2
View File
@@ -11,11 +11,20 @@ templ FilterBuilderRows(countNoun string) {
<div>
<template x-if="row.type === 'groupOpen'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="'margin-left:' + (row.depth * 14) + 'px'">
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
<!-- THE group mode control (2026-11-01): the header was a dead label
while the engine honored group modes all along — clicking it now
flips ALL ⇄ ANY for that group. Root (path=[]) is the whole-query
switch; the remove × hides there (nothing to remove). -->
<button type="button" class="fb-group-mode" :class="groupMode(row.path) === 'any' ? 'fb-group-any' : ''" :aria-pressed="groupMode(row.path) === 'any'" :title="groupMode(row.path) === 'any' ? 'ANY — at least one rule in this group matches. Click for ALL.' : 'ALL — every rule in this group must match. Click for ANY.'" @click="toggleGroupMode(row.path)">
<span x-text="row.path.length === 0 ? 'Match' : 'Group'"></span>
<span x-text="groupMode(row.path) === 'any' ? 'ANY' : 'ALL'"></span>
</button>
<span class="flex-1"></span>
<template x-if="row.path.length > 0">
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
</template>
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
<button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button>
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
</div>
</template>
<template x-if="row.type === 'rule'">