Comma-joined multi-picks matched ZERO: clauseFeed/clauseLibrary/ clauseCollection parsed each rule value as ONE uuid, but the builder's pick loop stores several picks as a single comma-joined value (["id1,id2"]) — uuid.Parse failed on the joined string, the id list came back empty, and "feeds in [A, B]" matched no episodes (one pick worked, which is why the briefing recipe survived). parseUUIDValues now splits every value on commas (trimming) before parsing, accepting both the builder's joined shape and hand-written separate elements — same leniency the suggest-side id lookup already has. The multi-value cells now say so: id-kind value inputs get the placeholder "pick one or more" (the only boxes on the page that accept several values; every other cell replaces its value). Pins: comma_joined_multi_feed_matches_both — a tree collection built with the exact comma-joined wire the client sends, asserting BOTH feeds' episodes match. Filter-builder vitest 35/35, container filter + episode-tree slices green.
104 lines
7.6 KiB
Templ
104 lines
7.6 KiB
Templ
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="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">
|
||
<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>
|
||
<!-- 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>
|
||
}
|