fix(filter-builder): id labels resolve at render — refresh keeps names
Refresh showed raw uuids in id-kind value cells (feed/library/ collection/added_by). Two defects, one story: 1. Only ids persist in filter trees; the client's id→label cache dies with the page. The quiet re-resolution fetch reused the suggest facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items + the tree's sibling rules as a constraint) — so an id whose media doesn't match got no row and no label. Live repro: "feed in [A,B,C] AND title contains Chapter" labeled one feed of three (live-request evidence: 200 with total:1). A label is a property of the entity row, not of what currently matches. Fix: pages that render a tree resolve labels SERVER-SIDE at render and embed the map on the mount (data-id-labels) — the collection editor (stored tree) and the bookshelf (URL qf). New SearchService.FilterTreeLabels reads the entity tables with no media join and no tree constraint; scoping rides the request identity through the scoped handle (podcast_feeds/collections RLS — an invisible id resolves to nothing; pinned), libraries mirror the facet's visibility join. A feed with ZERO episodes resolves its title — pinned; that case was structurally impossible before. 2. The bookshelf drawer never hydrated at page load at all: the only load-time hydrate calls live in the PARENT's x-init (initBookshelf), and Alpine initializes parents before nested components — builderData() was null there, so hydrateFromForm no-oped and a URL-qf refresh left the drawer with an empty seeded rule while the grid stayed filtered. The drawer now hydrates in its OWN init when the mount carries no data-tree; the init-order contract is pinned in vitest (185/185). The quiet suggest fetch remains only as a fallback for trees the SSR never saw; embedded labels make it a no-op on refresh. Interactive menus are untouched (their constrained, count-bearing behavior is correct for choosing). Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render, bookshelf qf library label, hidden library NOT labeled); filter/episode-tree/search-contract/RLS/playlist container slices green; contract_pins entry documents the embed contract.
This commit is contained in:
1 parent
c448fa438f
commit
65f257f377
12 files changed
+533
-80
No files matched your search
@@ -409,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// SSR id-label resolution for a URL-carried qf tree: the drawer's
|
||||
// value cells show NAMES from first paint instead of raw uuids
|
||||
// (2026-11-01 — same story as the collection editor above).
|
||||
labelsJSON := "{}"
|
||||
if qfRaw := c.QueryParam("qf"); qfRaw != "" {
|
||||
if t, perr := services.ParseFilterTree(qfRaw); perr == nil {
|
||||
labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -706,12 +716,14 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// with the rules-array storage — the tree is the only form.)
|
||||
scope := "books"
|
||||
treeJSON := "{}"
|
||||
var parsedTree *services.FilterTree
|
||||
if len(collection.FilterTree) > 0 {
|
||||
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
|
||||
scope = t.FilterScope()
|
||||
if b, merr := json.Marshal(t); merr == nil {
|
||||
treeJSON = string(b)
|
||||
}
|
||||
parsedTree = t
|
||||
}
|
||||
}
|
||||
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
||||
@@ -720,6 +732,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
fieldsJSON := string(fieldsPayload)
|
||||
|
||||
// SSR id-label resolution: the stored tree arrives as bare ids;
|
||||
// embed the id→name map so value cells render NAMES at first
|
||||
// paint (2026-11-01: the client's pick-time cache dies with the
|
||||
// page, and the quiet suggest fetch can't label ids whose media
|
||||
// doesn't match the tree's sibling rules — raw uuids on refresh).
|
||||
labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree)
|
||||
|
||||
colData := templates.CollectionData{
|
||||
ID: collectionID,
|
||||
Name: collection.Name,
|
||||
@@ -731,7 +750,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope).Render(c.Request().Context(), &buf)
|
||||
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -297,6 +297,32 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
||||
// detail page and the books-grid partial: smart evaluation, a dashboard
|
||||
// query section, or manual membership. Callers resolve the scope:
|
||||
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
||||
// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered
|
||||
// filter tree: walks the tree's id-kind rules, resolves uuids to display
|
||||
// names through the search engine (RLS/visibility ride the request
|
||||
// identity), and returns the JSON map embedded into the builder mount.
|
||||
// Best-effort: any failure yields "{}" and the page renders with the
|
||||
// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY).
|
||||
func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string {
|
||||
if tree == nil || len(tree.Children) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
labels := cfg.MediaHandler.SearchService().FilterTreeLabels(
|
||||
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree)
|
||||
if len(labels) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
b, merr := json.Marshal(labels)
|
||||
if merr != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
isSmart = services.IsCollectionSmart(collection)
|
||||
|
||||
Reference in new issue
Block a user