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
@@ -243,6 +243,11 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
|
||||
|
||||
// ExecuteSearch performs search and returns results with count
|
||||
// Public wrapper for shared search logic used by both JSON and HTML endpoints
|
||||
// SearchService exposes the shared search engine for SSR surfaces
|
||||
// that render a filter tree (the collection editor and bookshelf pages
|
||||
// resolve id labels through it — 2026-11-01).
|
||||
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
|
||||
|
||||
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||
return h.searchService.ExecuteSearch(ctx, params)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// SSR id-label resolution (2026-11-01): pages that render a filter tree
|
||||
// (the collection editor's stored tree, the bookshelf's URL qf) embed
|
||||
// the id→name map so value cells show NAMES at first paint.
|
||||
//
|
||||
// Why not reuse the suggest facet: its rows are media-constrained
|
||||
// (INNER JOIN media_items + the other rules as a constraint), so an id
|
||||
// whose media doesn't match the tree's sibling rules returns NO row and
|
||||
// its label is lost — live repro: feed-in-3 + title-contains-Chapter
|
||||
// labeled one feed of three. A label is a property of the ENTITY row,
|
||||
// not of what currently matches: resolution reads the source tables
|
||||
// directly, no media join, no tree constraint.
|
||||
//
|
||||
// Scoping rides the request identity through s.raw (the ScopedDB —
|
||||
// every statement declares app.user_id): podcast_feeds and collections
|
||||
// are RLS-governed (an invisible feed/collection resolves to nothing),
|
||||
// libraries mirror the facet's explicit visibility join, and users
|
||||
// (added_by) are plain lookups — the viewer already holds the id from
|
||||
// a legal pick, the resolver only names it.
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// maxResolveIDs bounds one field's resolution query (picks accumulate
|
||||
// per rule; menus cap far below this).
|
||||
const maxResolveIDs = 100
|
||||
|
||||
// FilterTreeLabels resolves every id-kind rule value in the tree to its
|
||||
// display name. Best-effort by design: a resolve failure degrades to
|
||||
// fewer labels (cells fall back to raw ids — the pre-fix behavior),
|
||||
// never a failed page render. Returns uuid → display.
|
||||
func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID, tree *FilterTree) map[string]string {
|
||||
labels := map[string]string{}
|
||||
if tree == nil {
|
||||
return labels
|
||||
}
|
||||
|
||||
// Collect ids per field, split comma-joined picks (the builder
|
||||
// stores one comma-joined value per multi-pick), cap the query.
|
||||
byField := map[string][]uuid.UUID{}
|
||||
var walk func(children []FilterChild)
|
||||
walk = func(children []FilterChild) {
|
||||
for _, c := range children {
|
||||
if len(c.Children) > 0 {
|
||||
walk(c.Children)
|
||||
continue
|
||||
}
|
||||
if c.Field == "" || len(c.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
f, ok := FilterFieldByKey(c.Field)
|
||||
if !ok || f.Suggest == "" {
|
||||
continue
|
||||
}
|
||||
if valueKindFor(f.Kind, c.Field) != VKId {
|
||||
continue
|
||||
}
|
||||
for _, id := range parseUUIDValues(c.Values) {
|
||||
if len(byField[c.Field]) >= maxResolveIDs {
|
||||
return
|
||||
}
|
||||
byField[c.Field] = append(byField[c.Field], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(tree.Children)
|
||||
|
||||
for field, ids := range byField {
|
||||
f, _ := FilterFieldByKey(field)
|
||||
idArgs := make([]uuid.UUID, len(ids))
|
||||
copy(idArgs, ids)
|
||||
var sqlStr string
|
||||
var args []any
|
||||
switch f.Suggest {
|
||||
case suggestLibrary:
|
||||
// The facet's visibility shape, minus the media join: a
|
||||
// hidden library is invisible even by id (family lens).
|
||||
sqlStr = `SELECT l.id::text, l.name
|
||||
FROM libraries l
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||
WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)`
|
||||
args = []any{idArgs, userID}
|
||||
case suggestFeed:
|
||||
// RLS-governed (subscription + visibility predicate): an
|
||||
// unsubscribed feed id resolves to nothing.
|
||||
sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
case suggestCollection:
|
||||
// RLS-governed (app_can_read_collection).
|
||||
sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
case suggestAddedBy:
|
||||
sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)`
|
||||
args = []any{idArgs}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
rows, err := s.raw.Query(ctx, sqlStr, args...)
|
||||
if err != nil {
|
||||
log.Printf("[filter-labels] resolve %q failed: %v", field, err)
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var v, display string
|
||||
if err := rows.Scan(&v, &display); err != nil {
|
||||
break
|
||||
}
|
||||
labels[v] = display
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return labels
|
||||
}
|
||||
Reference in new issue
Block a user