Files
bookhoard/templates/filter_builder.templ
T
John O'Keefe 2344f5686a 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.
2026-11-01 15:43:52 -05:00

131 lines
9.6 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
// filterBuilderRows is THE shared builder rows markup (item 3.3/3.4):
// one presentation for every builder mount — All-Books and collection
// smart editors. Purely presentational: every method/property lives on
// the shared filterBuilderExtra Alpine component (web/src/
// filter_builder.ts), whose vocabulary arrives from
// services.FilterFieldsPayload(scope).
templ FilterBuilderRows(countNoun string) {
<template x-for="row in builderRows" :key="row.key">
<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'">
<!-- 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>
</div>
</template>
<template x-if="row.type === 'rule'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="'margin-left:' + ((row.depth + 1) * 14) + 'px'">
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="toggleConnective(row)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
<!-- field picker: grouped menu -->
<div class="relative">
<button type="button" class="fb-picker" :class="{'fb-picker-active': row.rule.field !== ''}" @click="toggleMenu(row.key + ':field')" x-text="fieldLabelOf(row.rule.field)"></button>
<div class="fb-menu fb-menu-fields" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
<div class="fb-menu-filter-wrap">
<input type="text" class="fb-menu-filter" placeholder="Filter fields…" x-model="fieldFilter" @keydown.enter.prevent @click.stop/>
</div>
<div class="fb-menu-scroll">
<template x-for="grp in visibleFieldGroups" :key="grp.group">
<div>
<div class="fb-menu-group" x-text="grp.group"></div>
<template x-for="f in grp.fields" :key="f.key">
<button type="button" class="fb-menu-item" :class="row.rule.field === f.key ? 'fb-menu-item-active' : ''" @click="setRuleField(row.path, f.key)" x-text="f.label"></button>
</template>
</div>
</template>
<div class="fb-menu-empty" x-show="visibleFieldGroups.length === 0">No matching fields</div>
</div>
</div>
</div>
<!-- operator picker (negation lives here) -->
<div class="relative">
<button type="button" class="fb-picker fb-picker-op" :class="{'fb-negated-text': isNegated(row.rule)}" @click="toggleMenu(row.key + ':op')" x-text="opLabelOf(row.rule)"></button>
<div class="fb-menu" x-show="openMenu === row.key + ':op'" @click.outside="openMenu = ''">
<template x-for="o in opsFor(row.rule.field)" :key="o.key">
<button type="button" class="fb-menu-item" :class="[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']" @click="setRuleOp(row.path, o.key)" x-text="o.label"></button>
</template>
</div>
</div>
<!-- value cell: shape follows the operator + field kind -->
<template x-if="hasValueCell(row.rule) && valueArity(row.rule) === 'two'">
<span class="flex items-center gap-1">
<input type="text" class="input fb-value" :value="row.rule.values[0] || ''" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" placeholder="From"/>
<span class="text-xs" style="color: var(--text-secondary)">and</span>
<input type="text" class="input fb-value" :value="row.rule.values[1] || ''" @input.debounce.250ms="setRuleValue(row.path, 1, $el.value)" placeholder="To"/>
</span>
</template>
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
<span class="fb-value-cell relative flex items-center gap-1" :class="isIdValueRow(row.path) ? 'fb-chipbox flex-wrap' : ''">
<template x-if="isIdValueRow(row.path)">
<span class="contents">
<!-- picked values as removable chips: the box
wraps and grows instead of a 9rem input
you can't read past the first entry -->
<template x-for="chip in idChipsFor(row.path)" :key="chip.id">
<span class="fb-chip" :title="chip.label">
<span class="fb-chip-label" x-text="chip.label"></span>
<button type="button" class="fb-chip-x" :aria-label="'Remove ' + chip.label" @click="removeIdChip(row.path, chip.id)">×</button>
</span>
</template>
<input type="text" class="fb-value fb-chip-input" :value="idDraftFor(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @keydown="chipInputKeydown(row.path, $event)" :placeholder="valuePlaceholderFor(row.rule)" :aria-label="fieldLabelOf(row.rule.field) + ' values'"/>
</span>
</template>
<template x-if="!isIdValueRow(row.path)">
<span class="contents">
<input type="text" class="input fb-value" :value="valueCellText(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="valuePlaceholderFor(row.rule)"/>
<template x-if="fieldIsDays(row.rule.field)">
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
</template>
</span>
</template>
<!-- field-aware value menu: server JSON (the other
rules bound), or the field's fixed options.
Clicks anywhere inside the value cell never close
it — the opening focus-click must not race the
outside handler. -->
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="dismissSuggestions($event)">
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
<span x-text="s.display || s.value"></span>
<span class="text-xs" style="color: var(--text-secondary)" x-show="s.count > 0" x-text="s.count + ' books'"></span>
</button>
</template>
<div class="fb-menu-empty" x-show="suggestionsLoading === row.key">Loading…</div>
<div class="fb-menu-empty" x-show="suggestionsLoading !== row.key && !(suggestions[row.key] || []).length">No matching values</div>
</div>
</span>
</template>
<!-- delete rule -->
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove rule">×</button>
</div>
</template>
<template x-if="row.type === 'groupClose' && row.depth > 0">
<div class="fb-group-end" :style="'margin-left:' + (row.depth * 14) + 'px'"></div>
</template>
</div>
</template>
<!-- footer: add rule/group + the debounced live count
(the composed engine's true COUNT — what the surface will
show once applied) -->
<div class="flex items-center justify-between px-1 py-2">
<div class="flex items-center gap-1.5">
<button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button>
<button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button>
</div>
<span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text={ "matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')" }></span>
</div>
}