fix(filter-builder): id labels survive Apply — stored ids resolve to names

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.
This commit is contained in:
John O'Keefe committed 2026-11-01 00:11:57 -04:00
1 parent ed42111df1
commit 09f8f0875f
4 files changed
+87 -8

No files matched your search

+10
View File
@@ -650,6 +650,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
} }
assert.True(t, found, "the visible library is offered") 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 // BROWSE the library field UNDER rules (the blank-typed click): the
// id-source query must not choke on the unused typed parameter and // id-source query must not choke on the unused typed parameter and
// must list exactly the library holding the surviving books. // must list exactly the library holding the surviving books.
+8 -1
View File
@@ -1112,9 +1112,16 @@ func (s *SearchService) composedIDSourceValues(ctx context.Context, b *filterBui
fromTables, valueExpr, displayExpr, joinMedia, limitBind, libBind string) ([]FieldValue, error) { fromTables, valueExpr, displayExpr, joinMedia, limitBind, libBind string) ([]FieldValue, error) {
// The typed bind happens HERE and only when non-blank — an unused // The typed bind happens HERE and only when non-blank — an unused
// parameter is a 42P18 error (the blank-browse case binds nothing). // 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 := "" nameFilter := ""
if p.Typed != "" { 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 sqlStr := `SELECT ` + valueExpr + ` AS value, ` + displayExpr + ` AS display, COUNT(mi.id) AS count
FROM ` + fromTables + ` FROM ` + fromTables + `
+25
View File
@@ -409,6 +409,31 @@ describe("id-kind value cells", () => {
expect(c.valueCellText([0])).toBe("Books, Comics"); 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", () => { it("text cells keep writing straight through", () => {
const c = filterBuilderData(); const c = filterBuilderData();
c.filterTree = { c.filterTree = {
+44 -7
View File
@@ -479,6 +479,9 @@ export function filterBuilderData() {
// no prefill — start empty // 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 // treeJSON is the APPLY wire for collection mounts: the page's
// scope rides the tree's top-level key (the engine's scope head). // scope rides the tree's top-level key (the engine's scope head).
@@ -700,6 +703,30 @@ export function filterBuilderData() {
this.openMenu = ""; this.openMenu = "";
this._touch(); 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/ // isIdValueRow — the value cell stores IDS (library/collection/
// feed/added_by): display needs the label map, typing filters NAMES. // feed/added_by): display needs the label map, typing filters NAMES.
isIdValueRow(path: number[]): boolean { isIdValueRow(path: number[]): boolean {
@@ -830,13 +857,20 @@ export function filterBuilderData() {
void this.fetchSuggestions(rowKey, path, rule); void this.fetchSuggestions(rowKey, path, rule);
}, 250); }, 250);
}, },
async fetchSuggestions(rowKey: string, path: number[], rule: FilterRuleState): Promise<void> { async fetchSuggestions(
rowKey: string,
path: number[],
rule: FilterRuleState,
quiet = false,
): Promise<void> {
const meta = FIELD_BY_KEY.get(rule.field); const meta = FIELD_BY_KEY.get(rule.field);
if (!meta || !meta.suggest) return; if (!meta || !meta.suggest) return;
// Fixed-option fields never hit the server. // Fixed-option fields never hit the server.
if (meta.valueKind === "fixed") return; if (meta.valueKind === "fixed") return;
const typed = ( 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(); ).trim();
// 1 char is too little to filter on; EMPTY means BROWSE — the // 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 // 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; if (typed.length === 1) return;
// cookie auth carries the call; a null bearer must not strand it // cookie auth carries the call; a null bearer must not strand it
// Open the menu NOW (loading state) — never a blank flash. // Open the menu NOW (loading state) — never a blank flash.
this.suggestions[rowKey] = []; // QUIET mode (label resolution) touches no menu state.
this.suggestionsLoading = rowKey; if (!quiet) {
this.suggestionsOpen = rowKey; this.suggestions[rowKey] = [];
this.suggestionsLoading = rowKey;
this.suggestionsOpen = rowKey;
}
// The menu is computed under the suggest-constraint tree: AND // The menu is computed under the suggest-constraint tree: AND
// siblings keep narrowing, OR siblings drop (a same-field pick // siblings keep narrowing, OR siblings drop (a same-field pick
// negates in, taking its value off the list), and the edited // negates in, taking its value off the list), and the edited
@@ -876,10 +913,10 @@ export function filterBuilderData() {
for (const s of data.results ?? []) { for (const s of data.results ?? []) {
if (s.display) this.idLabels[s.value] = s.display; if (s.display) this.idLabels[s.value] = s.display;
} }
this.suggestions[rowKey] = data.results ?? []; if (!quiet) this.suggestions[rowKey] = data.results ?? [];
} catch (error) { } catch (error) {
console.warn("suggestion fetch failed", error); console.warn("suggestion fetch failed", error);
this.suggestions[rowKey] = []; if (!quiet) this.suggestions[rowKey] = [];
} finally { } finally {
if (this.suggestionsLoading === rowKey) this.suggestionsLoading = ""; if (this.suggestionsLoading === rowKey) this.suggestionsLoading = "";
} }