From 09f8f0875f40a5fe826df3c83506c03be5d756ee Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Nov 2026 00:11:57 -0400 Subject: [PATCH] =?UTF-8?q?fix(filter-builder):=20id=20labels=20survive=20?= =?UTF-8?q?Apply=20=E2=80=94=20stored=20ids=20resolve=20to=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library cell reverted to the raw uuid after Apply: the apply flow reloads the page, the SSR-preloaded tree re-ingests, and idLabels — populated only by suggestion PICKS — starts empty, so valueCellText falls back to the id. Same on any fresh load of a stored id-rule. Two halves: - Server: the id-source suggest query's typed filter (library/ collection/added_by/feed) now also exact-matches a comma-separated ID list alongside the name ILIKE — so a suggest call with the stored ids returns their labeled rows. No new endpoint; the menu and the resolver share one surface. - Client: fetchSuggestions gains a QUIET mode (fills idLabels, touches no menu state), id rows fall back to their stored ids as the typed lookup when no draft is present, and init() calls resolveIdLabels() after tree ingest — one quiet fetch per id-kind rule resolves every stored id to its name from the first paint after reload. Pins: the Go side (suggest with a library's UUID returns the row with the library NAME as display), the client side (resolveIdLabels fills labels, the cell renders the name, no menu opens). 35 filter-builder vitest cases green; container filter slice green. --- cmd/server/tests/filter_builder_test.go | 10 +++++ internal/services/filter_query.go | 9 ++++- web/src/filter_builder.test.ts | 25 ++++++++++++ web/src/filter_builder.ts | 51 +++++++++++++++++++++---- 4 files changed, 87 insertions(+), 8 deletions(-) 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 = ""; }