diff --git a/cmd/server/tests/filter_builder_test.go b/cmd/server/tests/filter_builder_test.go index d8987130..88c3e929 100644 --- a/cmd/server/tests/filter_builder_test.go +++ b/cmd/server/tests/filter_builder_test.go @@ -650,6 +650,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) { } assert.True(t, found, "the visible library is offered") + // ID LOOKUP (2026-11-01): typing a stored ID resolves its label — + // the builder's post-Apply reload path resolves stored ids to names + // through this same call. + status, results = suggest("library", libUUID.String(), "") + require.Equal(t, http.StatusOK, status) + require.NotEmpty(t, results, "the id lookup returns its row") + assert.Equal(t, libUUID.String(), results[0]["value"]) + disp, _ := results[0]["display"].(string) + assert.Contains(t, disp, "Test Builder", "the id resolves to the library NAME") + // BROWSE the library field UNDER rules (the blank-typed click): the // id-source query must not choke on the unused typed parameter and // must list exactly the library holding the surviving books. diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go index 7132ea8f..483281cd 100644 --- a/internal/services/filter_query.go +++ b/internal/services/filter_query.go @@ -1112,9 +1112,16 @@ func (s *SearchService) composedIDSourceValues(ctx context.Context, b *filterBui fromTables, valueExpr, displayExpr, joinMedia, limitBind, libBind string) ([]FieldValue, error) { // The typed bind happens HERE and only when non-blank — an unused // parameter is a 42P18 error (the blank-browse case binds nothing). + // Typing filters the NAME (ILIKE) — and also exact-matches a + // comma-separated ID list: the builder resolves a freshly ingested + // tree's stored ids to labels with the same call (2026-11-01: the + // library cell reverted to raw ids after Apply — labels only ever + // came from suggestion picks). nameFilter := "" if p.Typed != "" { - nameFilter = " AND " + displayExpr + " ILIKE '%' || " + b.bind(p.Typed) + " || '%'" + tb := b.bind(p.Typed) + nameFilter = " AND (" + displayExpr + " ILIKE '%' || " + tb + " || '%'" + + " OR " + valueExpr + "::text = ANY(string_to_array(" + tb + ", ',')))" } sqlStr := `SELECT ` + valueExpr + ` AS value, ` + displayExpr + ` AS display, COUNT(mi.id) AS count FROM ` + fromTables + ` diff --git a/web/src/filter_builder.test.ts b/web/src/filter_builder.test.ts index f207a7a0..bff80ec5 100644 --- a/web/src/filter_builder.test.ts +++ b/web/src/filter_builder.test.ts @@ -409,6 +409,31 @@ describe("id-kind value cells", () => { expect(c.valueCellText([0])).toBe("Books, Comics"); }); + it("a freshly ingested tree resolves stored ids to labels (the Apply reload)", async () => { + const c = filterBuilderData(); + c.filterTree = { + mode: "all", + children: [{ field: "library", op: "in", values: ["lib-9"] }], + }; + let url = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (u: string | URL) => { + url = String(u); + return { + ok: true, + json: async () => ({ results: [{ value: "lib-9", display: "Ebooks", count: 4 }] }), + }; + }), + ); + 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"); + expect(c.valueCellText([0])).toBe("Ebooks"); + expect(c.suggestionsOpen).toBe("", "label resolution never opens a menu"); + vi.unstubAllGlobals(); + }); + it("text cells keep writing straight through", () => { const c = filterBuilderData(); c.filterTree = { diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index 548945af..47e38b6c 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -479,6 +479,9 @@ export function filterBuilderData() { // no prefill — start empty } } + // Stored id values arrive bare: resolve their labels now so the + // cells render names from the first paint after reload. + this.resolveIdLabels(); }, // treeJSON is the APPLY wire for collection mounts: the page's // scope rides the tree's top-level key (the engine's scope head). @@ -700,6 +703,30 @@ export function filterBuilderData() { this.openMenu = ""; this._touch(); }, + // resolveIdLabels — a freshly ingested tree (page load, the Apply + // reload) carries id-kind values with no labels in sight; one QUIET + // fetch per id-kind rule fills idLabels so the cells render names, + // not uuids (2026-11-01: the library cell reverted to the id after + // Apply because labels only ever came from suggestion picks). + resolveIdLabels(): void { + const visit = (node: FilterRuleState | FilterGroupState, path: number[]): void => { + if (isRule(node)) { + const meta = FIELD_BY_KEY.get(node.field); + if (meta?.valueKind !== "id") return; + const ids = node.values + .join(",") + .split(",") + .filter((v) => v.trim() !== ""); + if (ids.length === 0) return; + if (ids.every((v) => this.idLabels[v] !== undefined)) return; + void this.fetchSuggestions(path.join("-"), path, node, true); + return; + } + node.children.forEach((child, idx) => visit(child, [...path, idx])); + }; + this.filterTree.children.forEach((child, idx) => visit(child, [idx])); + }, + // isIdValueRow — the value cell stores IDS (library/collection/ // feed/added_by): display needs the label map, typing filters NAMES. isIdValueRow(path: number[]): boolean { @@ -830,13 +857,20 @@ export function filterBuilderData() { void this.fetchSuggestions(rowKey, path, rule); }, 250); }, - async fetchSuggestions(rowKey: string, path: number[], rule: FilterRuleState): Promise { + async fetchSuggestions( + rowKey: string, + path: number[], + rule: FilterRuleState, + quiet = false, + ): Promise { const meta = FIELD_BY_KEY.get(rule.field); if (!meta || !meta.suggest) return; // Fixed-option fields never hit the server. if (meta.valueKind === "fixed") return; const typed = ( - this.isIdValueRow(path) ? (this.idDrafts[path.join("-")] ?? "") : (rule.values[0] ?? "") + this.isIdValueRow(path) + ? (this.idDrafts[path.join("-")] ?? rule.values.join(",")) + : (rule.values[0] ?? "") ).trim(); // 1 char is too little to filter on; EMPTY means BROWSE — the // user clicked the box to see the field's top values under the @@ -845,9 +879,12 @@ export function filterBuilderData() { if (typed.length === 1) return; // cookie auth carries the call; a null bearer must not strand it // Open the menu NOW (loading state) — never a blank flash. - this.suggestions[rowKey] = []; - this.suggestionsLoading = rowKey; - this.suggestionsOpen = rowKey; + // QUIET mode (label resolution) touches no menu state. + if (!quiet) { + this.suggestions[rowKey] = []; + this.suggestionsLoading = rowKey; + this.suggestionsOpen = rowKey; + } // The menu is computed under the suggest-constraint tree: AND // siblings keep narrowing, OR siblings drop (a same-field pick // negates in, taking its value off the list), and the edited @@ -876,10 +913,10 @@ export function filterBuilderData() { for (const s of data.results ?? []) { if (s.display) this.idLabels[s.value] = s.display; } - this.suggestions[rowKey] = data.results ?? []; + if (!quiet) this.suggestions[rowKey] = data.results ?? []; } catch (error) { console.warn("suggestion fetch failed", error); - this.suggestions[rowKey] = []; + if (!quiet) this.suggestions[rowKey] = []; } finally { if (this.suggestionsLoading === rowKey) this.suggestionsLoading = ""; }