Files
bookhoard/templates/filter_builder.templ
T
John O'Keefe 4f71eda6be feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)
The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
2026-10-30 19:22:17 -04:00

104 lines
7.7 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`">
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
<span class="flex-1"></span>
<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'">
<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="if (row.chipClickable) toggleRuleConnective(row.path)" :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">
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/>
<template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'">
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></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="if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''">
<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>
}