fix(filter-builder): multi-pick id values matched nothing; hint the multi boxes

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.
This commit is contained in:
John O'Keefe committed 2026-11-01 00:20:31 -04:00
1 parent 09f8f0875f
commit cce488b76f
6 files changed
+78 -22

No files matched your search

+42
View File
@@ -943,6 +943,48 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
// Sequence config rides a data-json attribute — templ escapes quotes.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
// MULTI-PICK wire shape (2026-11-01 user report): the builder's pick
// loop stores several feeds as ONE comma-joined value — "feeds in
// [A, B]" must match BOTH feeds' episodes, not zero.
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "TreeColl Feed B " + marker,
})
require.NoError(t, err)
bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "TreeColl B Ep", 1)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id = $1`, bEp.ID)
subscribeRaw(t, setup, regUUID, feedB.ID)
t.Run("comma_joined_multi_feed_matches_both", func(t *testing.T) {
joined := feedAID + "," + uuid.UUID(feedB.ID.Bytes).String()
multi, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "any",
"children": []map[string]interface{}{
{"field": "feed", "op": "in", "values": []string{joined}},
},
})
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test TreeColl Multi `+marker+`","filter_tree":`+string(multi)+`}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
h := string(body)
assert.Contains(t, h, "TreeColl New", "feed A's episodes match")
assert.Contains(t, h, "TreeColl B Ep", "feed B's episodes match")
assert.Contains(t, h, "TreeColl Old", "no date rule — every episode of both feeds")
})
// The Daily Fiction briefing AS A TREE (item 2.5's shape, unified DSL).
briefing, _ := json.Marshal(map[string]interface{}{
"scope": "episodes", "mode": "all",
+21 -18
View File
@@ -379,6 +379,24 @@ func clauseFileAvailability(r FilterRule, b *filterBuilder) string {
}
// clauseAddedBy — the admin who added the book.
// parseUUIDValues — the wire shape for id-valued rules: the builder's
// pick loop stores picked ids as ONE comma-joined value
// (["id1,id2"]), while hand-written trees may carry separate elements.
// Both are legal; split on commas, trim, and keep what parses. An
// unsplit parse matched NOTHING for multi-picks (2026-11-01 user
// report: two feeds in "feeds in" matched zero episodes).
func parseUUIDValues(values []string) []uuid.UUID {
ids := make([]uuid.UUID, 0, len(values))
for _, v := range values {
for _, part := range strings.Split(v, ",") {
if id, err := uuid.Parse(strings.TrimSpace(part)); err == nil {
ids = append(ids, id)
}
}
}
return ids
}
func clauseAddedBy(r FilterRule, b *filterBuilder) string {
switch r.Op {
case OpIs:
@@ -610,12 +628,7 @@ func clauseCollection(r FilterRule, b *filterBuilder) string {
}
return "(NOT " + ex + ")"
}
ids := make([]uuid.UUID, 0, len(r.Values))
for _, v := range r.Values {
if id, err := uuid.Parse(v); err == nil {
ids = append(ids, id)
}
}
ids := parseUUIDValues(r.Values)
ex := fmt.Sprintf(`(EXISTS (
SELECT 1 FROM collection_items ci
JOIN collections c ON c.id = ci.collection_id
@@ -633,12 +646,7 @@ func clauseCollection(r FilterRule, b *filterBuilder) string {
// library IDs; visibility is enforced by the mandatory head regardless
// (a rule naming a hidden library simply matches nothing).
func clauseLibrary(r FilterRule, b *filterBuilder) string {
ids := make([]uuid.UUID, 0, len(r.Values))
for _, v := range r.Values {
if id, err := uuid.Parse(v); err == nil {
ids = append(ids, id)
}
}
ids := parseUUIDValues(r.Values)
if r.Op == OpIs {
return "(l.id = ANY(" + b.bind(ids) + "::uuid[]))"
}
@@ -653,12 +661,7 @@ func clauseLibrary(r FilterRule, b *filterBuilder) string {
// naming an unsubscribed feed matches nothing).
func clauseFeed(r FilterRule, b *filterBuilder) string {
b.needJoin("pf", "LEFT JOIN podcast_feeds pf ON pf.id = mi.source_feed_id")
ids := make([]uuid.UUID, 0, len(r.Values))
for _, v := range r.Values {
if id, err := uuid.Parse(v); err == nil {
ids = append(ids, id)
}
}
ids := parseUUIDValues(r.Values)
in := "(pf.id = ANY(" + b.bind(ids) + "::uuid[]))"
switch r.Op {
case OpIs, OpIn:
+1 -1
View File
@@ -60,7 +60,7 @@ templ FilterBuilderRows(countNoun string) {
</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="fieldIsDays(row.rule.field) ? 'days' : 'value'"/>
<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>
+1 -1
View File
@@ -35,7 +35,7 @@ func FilterBuilderRows(countNoun string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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=\"fieldIsDays(row.rule.field) ? 'days' : 'value'\"><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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside 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, roLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<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\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside 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)\"><spaLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+4 -2
View File
@@ -428,9 +428,11 @@ describe("id-kind value cells", () => {
);
c.resolveIdLabels();
await vi.waitFor(() => expect(c.idLabels["lib-9"]).toBe("Ebooks"));
expect(url).toContain("library=lib-9", "the stored id rides as the typed lookup");
// the stored id rides as the typed lookup
expect(url).toContain("library=lib-9");
expect(c.valueCellText([0])).toBe("Ebooks");
expect(c.suggestionsOpen).toBe("", "label resolution never opens a menu");
// label resolution never opens a menu
expect(c.suggestionsOpen).toBe("");
vi.unstubAllGlobals();
});
+9
View File
@@ -727,6 +727,15 @@ export function filterBuilderData() {
this.filterTree.children.forEach((child, idx) => visit(child, [idx]));
},
// valuePlaceholderFor — the value cell's hint. Id-kind cells are
// the few MULTI-VALUE boxes (pick several; the menu keeps toggling
// them in) — say so, because nothing else on the page does.
valuePlaceholderFor(rule: FilterRuleState): string {
if (this.fieldIsDays(rule.field)) return "days";
if (FIELD_BY_KEY.get(rule.field)?.valueKind === "id") return "pick one or more";
return "value";
},
// isIdValueRow — the value cell stores IDS (library/collection/
// feed/added_by): display needs the label map, typing filters NAMES.
isIdValueRow(path: number[]): boolean {