feat(series): multi-field search, sort-direction toggle, apply placement

Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
This commit is contained in:
John O'Keefe committed 2026-10-27 19:05:23 -04:00
1 parent 14285ff314
commit 96c1228805
11 files changed
+504 -149

No files matched your search

+37 -1
View File
@@ -164,13 +164,49 @@ func TestSeriesSortAndFilter(t *testing.T) {
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
})
t.Run("base sort + direction toggle composes registry keys", func(t *testing.T) {
// The toolbar sends base keys + dir; defaults apply when dir is
// absent, and both directions of every base key are reachable.
cases := []struct {
query string
want []string
}{
{"sort=name", []string{"Apple", "Mango", "Zebra"}}, // name asc default
{"sort=name&dir=desc", []string{"Zebra", "Mango", "Apple"}}, // toggle flips
{"sort=books&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // books desc default, flipped
{"sort=progress&dir=asc", []string{"Apple", "Zebra", "Mango"}}, // progress desc default, flipped
{"sort=author", []string{"Mango", "Apple", "Zebra"}}, // author asc default
}
for _, tc := range cases {
got := renderedNames("&" + tc.query)
require.Len(t, got, 3, tc.query)
for i, want := range tc.want {
assert.Contains(t, got[i], want, "%s: position %d", tc.query, i)
}
}
})
t.Run("name query filters", func(t *testing.T) {
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
// ILIKE is case-insensitive and substring-shaped.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
})
t.Run("author query filters on any book's author", func(t *testing.T) {
t.Run("multi-field query matches authors and book titles", func(t *testing.T) {
// Author of Mango is "Alpha One" — the query no longer needs the
// separate author box.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=alpha+one"))
// "Zebra Book 2" is a book TITLE inside the Zebra series: the
// series must surface for a title match too.
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=zebra+book+2"))
// Nothing matches anywhere.
assert.Empty(t, renderedNames("&q=nobody-nowhere"))
})
t.Run("author param (API back-compat) filters on any book's author", func(t *testing.T) {
// The toolbar no longer renders a separate author box (the
// multi-field q covers authors), but the param stays live for
// the JSON API and deep links.
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
assert.Empty(t, renderedNames("&author=nobody"))
})