feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
+95
-108
@@ -170,114 +170,98 @@ templ BookShelf(
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Filter by author"
|
||||
class="input"
|
||||
list="author-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||
/>
|
||||
<datalist id="author-datalist"></datalist>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tags_filter"
|
||||
placeholder="Filter by tags"
|
||||
class="input"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||
@keydown="onTagFilterKeydown($event)"
|
||||
@blur="hideTagDropdown()"
|
||||
/>
|
||||
<div
|
||||
x-show="showTagDropdown"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
|
||||
>
|
||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||
@click="selectTagSuggestion(sug.value)"
|
||||
>
|
||||
<span x-text="sug.value"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
|
||||
<input
|
||||
type="text"
|
||||
name="series_filter"
|
||||
placeholder="Filter by series"
|
||||
class="input"
|
||||
list="series-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||
/>
|
||||
<datalist id="series-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Narrator</label>
|
||||
<input
|
||||
type="text"
|
||||
name="narrator_filter"
|
||||
placeholder="Filter by narrator"
|
||||
class="input"
|
||||
list="narrator-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchNarratorValues($el)"
|
||||
/>
|
||||
<datalist id="narrator-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
name="language_filter"
|
||||
placeholder="Filter by language"
|
||||
class="input"
|
||||
list="language-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||
/>
|
||||
<datalist id="language-datalist"></datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" name="year_min" placeholder="From" class="input"/>
|
||||
<input type="number" name="year_max" placeholder="To" class="input"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</label>
|
||||
<input
|
||||
type="button"
|
||||
id="has-cover-tristate"
|
||||
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
||||
:class="{
|
||||
'state-null': hasCoverState === null,
|
||||
'state-true': hasCoverState === true,
|
||||
'state-false': hasCoverState === false
|
||||
}"
|
||||
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
|
||||
@click="cycleHasCover()"
|
||||
/>
|
||||
<template x-if="hasCoverState !== null">
|
||||
<input
|
||||
type="hidden"
|
||||
name="has_cover"
|
||||
:value="hasCoverState ? 'true' : 'false'"
|
||||
/>
|
||||
<!-- The rule builder (faceted plan Phase 9): the drawer's
|
||||
ONLY filter UI. Templ owns the look, Alpine owns the
|
||||
rule-tree state (the sanctioned island — same pattern
|
||||
as the chips bar; server JSON rendered by Alpine, no
|
||||
innerHTML templating). Rows are a flat projection of
|
||||
the tree (depth = indent); groups nest via + group. -->
|
||||
<div x-data="filterBuilderExtra" x-cloak
|
||||
x-effect="if (filtersOpen === true) { ensureReadyRule(); }"
|
||||
>
|
||||
<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' : 'Match'"></span>
|
||||
<button type="button" class="fb-mode-btn" :class="row.group.mode === 'all' ? 'fb-mode-active' : ''" @click="setGroupMode(row.path, 'all')">ALL</button>
|
||||
<button type="button" class="fb-mode-btn" :class="row.group.mode === 'any' ? 'fb-mode-active' : ''" @click="setGroupMode(row.path, 'any')">ANY</button>
|
||||
<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" x-show="row.depth > 0" @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`">
|
||||
<span class="fb-connective" x-show="!row.isFirst" x-text="row.connective"></span>
|
||||
<!-- 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" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
|
||||
<template x-for="grp in fieldGroups" :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>
|
||||
</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="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)" @click.outside="suggestionsOpen = ''" :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="valueArity(row.rule) === 'one' && fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : ''"></span>
|
||||
</template>
|
||||
<!-- field-aware value menu: server JSON (the other
|
||||
rules bound), or the field's fixed options -->
|
||||
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="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>
|
||||
</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 grid will
|
||||
show once applied) -->
|
||||
<div class="flex items-center gap-2 pt-2 flex-wrap">
|
||||
<button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button>
|
||||
<button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button>
|
||||
<span class="flex-1"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text="matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 book matches' : matchCount + ' books match')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||
@@ -300,7 +284,10 @@ templ BookShelf(
|
||||
<button
|
||||
type="button"
|
||||
class="badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
style="background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;"
|
||||
:class="chip.negated ? 'fb-chip-negated' : ''"
|
||||
:style="chip.negated
|
||||
? 'background-color: color-mix(in srgb, var(--status-danger) 12%, transparent); color: var(--status-danger); border: 1px solid color-mix(in srgb, var(--status-danger) 40%, transparent);'
|
||||
: 'background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;'"
|
||||
@click="removeFilterChip(chip.param)"
|
||||
>
|
||||
<span x-text="chip.label"></span>
|
||||
|
||||
Reference in new issue
Block a user