From c747f0a76cbf7bcadc8e2b2d721d670d5a73a8c6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 20 Oct 2026 15:25:57 -0400 Subject: [PATCH] feat(filters): per-row AND/OR (Excel-style chips) + browse-on-click value menus (user report) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-rule connectives: the chip before each rule flips that rule between AND and OR — OR-joined neighbors merge into an invisible OR-block, AND pulls them back out. The tree stays the single source of truth (implicit blocks are a client marker the server ignores), so deep links, saved filters, and the composed engine are untouched. Group-mode pills are gone; explicit groups remain as pure structure (+ group). Compile limit, same as Excel: flat chips express AND-of-OR-groups; a top-level OR of AND-blocks still needs nesting. - Browse-on-click: clicking an empty value box now fetches the field's top values UNDER the other rules (blank typed = browse, not a similarity filter) — 'assign an author, click Publisher, see their publishers' without typing. Server: the composed suggestion engine trims the typed query; client: the >=2-char guard no longer blocks the empty query, browse limit 15 vs 8 while typing. - Tests: the browse case pinned (blank sentinel + rules → the field's values under those rules, zero-match values absent). --- cmd/server/tests/filter_builder_test.go | 10 ++ internal/services/filter_query.go | 4 + templates/bookshelf.templ | 6 +- templates/bookshelf_templ.go | 10 +- web/src/filter_builder.ts | 137 +++++++++++++++++++----- web/static/input.css | 5 + web/static/style.css | 2 +- 7 files changed, 138 insertions(+), 36 deletions(-) diff --git a/cmd/server/tests/filter_builder_test.go b/cmd/server/tests/filter_builder_test.go index 653cb731..f08943a1 100644 --- a/cmd/server/tests/filter_builder_test.go +++ b/cmd/server/tests/filter_builder_test.go @@ -516,6 +516,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) { require.Equal(t, http.StatusOK, status) assert.NotContains(t, valuesOf(results), "Amara Voss", "zero-match values never offered") + // BROWSE (the user's click-the-box case): a blank typed query lists + // the field's top values UNDER the other rules — "assign an author, + // click publisher, see their publishers" without typing anything. + // The wire sends a single space as the sentinel. + status, results = suggest("genre", " ", qfAll(qfRule("author", "contains", "Quill"))) + require.Equal(t, http.StatusOK, status) + assert.Contains(t, valuesOf(results), "fantasy", "browse shows the field's values under the rules") + assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered") + assert.NotContains(t, valuesOf(results), "history", "values outside the rules never offered") + // Library field menu: visibility-scoped, id values + name display // (this test's fixture libraries are named "Test Builder Sug …"). status, results = suggest("library", "Test Builder", "") diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go index 19f6cb72..91593f71 100644 --- a/internal/services/filter_query.go +++ b/internal/services/filter_query.go @@ -863,6 +863,10 @@ func (s *SearchService) ComposedFieldValues(ctx context.Context, p ComposedSugge if p.Limit <= 0 { p.Limit = 50 } + // A blank typed query means BROWSE (the user clicked the value box + // to see what exists under the other rules) — not a similarity + // filter for a single space. + p.Typed = strings.TrimSpace(p.Typed) b := newFilterBuilder(p.UserID) typedBind := b.bind(p.Typed) diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 4aabbb87..b647a3b5 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -183,9 +183,7 @@ templ BookShelf(