fix(podcasts): code-review pass over the session's UI surfaces

Findings from the systematic gotcha-class review before the browser
pass:

- The collection smart editor's Apply derived the collection NAME from
  document.title — which parses to "Smart Collection", RENAMING the
  collection on every apply. The server now renders the real name
  (data-collection-name) and the write path refuses to proceed without
  it.
- TestRenderingPartialsRender pinned the pre-episode-collections
  empty-state copy ("No books in this collection yet.") — updated to
  the shipped label ("Nothing in this collection yet."; collections
  hold episodes now).
- The deleted handler-local rule comparators (evaluateRule/
  compareValues — removed in item 3.4's single-evaluator refactor)
  left behind two unit-test files testing the duplicates; deleted.
  Their semantics remain covered at the service level
  (TestEvaluateRules_* over FieldMatches/EvaluateRules) and
  TestTextToString (which actually pins utils.PgTextString) stays.

Everything else reviewed clean against the gotcha classes: templ
whole-expression attrs, ?={ boolean attrs, JSONArg quoting in
x-init/data attrs, Alpine CSP store paths, htmx form-encoding, the
" assert escaping, and the aria-label selector contract on the
player's reload listener.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:38:39 -04:00
1 parent 94bd69d22d
commit c54430c254
6 files changed
+75 -552

No files matched your search

-415
View File
@@ -1,415 +0,0 @@
package handlers
import (
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
// Test collection rule matching with various operators
func TestEvaluateRule_Equals_StringMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.True(t, result)
}
func TestEvaluateRule_Equals_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_NotEquals_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
assert.True(t, result)
}
func TestEvaluateRule_NotEquals_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_Contains_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "contains", "asimov")
assert.True(t, result)
}
func TestEvaluateRule_Contains_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "contains", "clarke")
assert.False(t, result)
}
func TestEvaluateRule_NotContains_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "not_contains", "clarke")
assert.True(t, result)
}
func TestEvaluateRule_StartsWith_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "starts_with", "Foun")
assert.True(t, result)
}
func TestEvaluateRule_StartsWith_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "starts_with", "Robots")
assert.False(t, result)
}
func TestEvaluateRule_EndsWith_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "ends_with", "tion")
assert.True(t, result)
}
func TestEvaluateRule_GreaterThan_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.True(t, result)
}
func TestEvaluateRule_GreaterThan_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.False(t, result)
}
func TestEvaluateRule_LessThan_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.True(t, result)
}
func TestEvaluateRule_LessThan_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.False(t, result)
}
func TestEvaluateRule_NullField(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{Valid: false}, // NULL
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.False(t, result, "Should return false for NULL fields")
}
func TestCompareValues_CaseInsensitive(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "contains", "science fiction")
assert.True(t, result, "Contains should be case-insensitive")
result = handler.compareValues("Science Fiction", "contains", "SCIENCE FICTION")
assert.True(t, result)
}
func TestCompareValues_EmptyString(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("", "contains", "test")
assert.False(t, result, "Empty string should not contain anything")
result = handler.compareValues("test", "contains", "")
assert.True(t, result, "Non-empty string contains empty string")
}
func TestCompareValues_NumericEdgeCases(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("0", "greater_than", "-1")
assert.True(t, result)
result = handler.compareValues("9999", "less_than", "10000")
assert.True(t, result)
}
// Test rule checking with multiple rules
func TestCheckRulesAgainstBook_MatchesFirstRule(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.NotEmpty(t, reason, "Should match at least one rule")
assert.Contains(t, reason, "genre")
}
func TestCheckRulesAgainstBook_MatchesSecondRule(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.NotEmpty(t, reason, "Should match second rule")
assert.Contains(t, reason, "author")
}
func TestCheckRulesAgainstBook_NoMatches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Mystery", Valid: true},
Author: pgtype.Text{String: "Agatha Christie", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 1950, Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.Empty(t, reason, "Should not match any rules")
}
func TestCheckRulesAgainstBook_EmptyRules(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []map[string]interface{}{}
reason := handler.checkRulesAgainstBook(item, rules)
assert.Empty(t, reason, "Empty rules should return empty reason")
}
// Test complex rule scenarios
func TestComplexRule_MultipleConditions(t *testing.T) {
handler := &CollectionHandler{}
// Book: "Foundation" by Isaac Asimov, Sci-Fi, 1951
item := database.MediaItems{
Title: "Foundation",
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 1951, Valid: true},
Series: pgtype.Text{String: "Foundation", Valid: true},
}
tests := []struct {
name string
field string
operator string
value string
expected bool
}{
{
name: "Match genre exactly",
field: "genre",
operator: "equals",
value: "Science Fiction",
expected: true,
},
{
name: "Match author substring",
field: "author",
operator: "contains",
value: "asimov",
expected: true,
},
{
name: "Match year greater than",
field: "copyright_year",
operator: "greater_than",
value: "1950",
expected: true,
},
{
name: "Match year less than",
field: "copyright_year",
operator: "less_than",
value: "2000",
expected: true,
},
{
name: "Match series starts with",
field: "series",
operator: "starts_with",
value: "Found",
expected: true,
},
{
name: "Match series ends with",
field: "series",
operator: "ends_with",
value: "tion",
expected: true,
},
{
name: "No match for genre",
field: "genre",
operator: "equals",
value: "Fantasy",
expected: false,
},
{
name: "No match for author",
field: "author",
operator: "contains",
value: "Tolkien",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.evaluateRule(item, tt.field, tt.operator, tt.value)
assert.Equal(t, tt.expected, result)
})
}
}
// Test edge cases and error handling
func TestEvaluateRule_InvalidOperator(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "invalid_operator", "Science Fiction")
assert.False(t, result, "Invalid operator should return false")
}
func TestEvaluateRule_NonExistentField(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "nonexistent_field", "equals", "value")
assert.False(t, result, "Non-existent field should return false")
}
func TestCompareValues_InvalidOperator(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("test", "invalid_op", "value")
assert.False(t, result, "Invalid comparison operator should return false")
}
func TestCompareValues_NumericConversion(t *testing.T) {
handler := &CollectionHandler{}
// Valid numeric strings
result := handler.compareValues("100", "greater_than", "99")
assert.True(t, result)
result = handler.compareValues("99", "less_than", "100")
assert.True(t, result)
// Invalid numeric strings (should return false)
result = handler.compareValues("abc", "greater_than", "100")
assert.False(t, result, "Non-numeric string should not compare as greater than")
result = handler.compareValues("100", "greater_than", "xyz")
assert.False(t, result, "Non-numeric value should not allow comparison")
}
-83
View File
@@ -3,95 +3,12 @@ package handlers
import ( import (
"testing" "testing"
"bookhoard/internal/database"
"bookhoard/internal/utils" "bookhoard/internal/utils"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestCompareValues_Equals(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "equals", "Science Fiction")
assert.True(t, result)
result = handler.compareValues("Science Fiction", "equals", "Fantasy")
assert.False(t, result)
}
func TestCompareValues_Contains(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Isaac Asimov", "contains", "asimov")
assert.True(t, result)
result = handler.compareValues("Isaac Asimov", "contains", "clarke")
assert.False(t, result)
}
func TestCompareValues_GreaterThan(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("2021", "greater_than", "2000")
assert.True(t, result)
result = handler.compareValues("1999", "greater_than", "2000")
assert.False(t, result)
}
func TestCompareValues_NotEquals(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "not_equals", "Fantasy")
assert.True(t, result)
result = handler.compareValues("Science Fiction", "not_equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_Genre(t *testing.T) {
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.True(t, result)
result = handler.evaluateRule(item, "genre", "equals", "Fantasy")
assert.False(t, result)
}
func TestEvaluateRule_Author(t *testing.T) {
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "author", "contains", "asimov")
assert.True(t, result)
result = handler.evaluateRule(item, "author", "contains", "clarke")
assert.False(t, result)
}
func TestEvaluateRule_CopyrightYear(t *testing.T) {
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.True(t, result)
result = handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.False(t, result)
}
func TestTextToString(t *testing.T) { func TestTextToString(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+1
View File
@@ -20,6 +20,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
<body <body
class={ "theme-" + user.Theme } class={ "theme-" + user.Theme }
x-data="collectionRules" x-data="collectionRules"
data-collection-name={ collection.Name }
x-init={ "initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")" } x-init={ "initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")" }
> >
@Header(user, "/collections") @Header(user, "/collections")
+64 -51
View File
@@ -71,20 +71,33 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" x-data=\"collectionRules\" x-init=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" x-data=\"collectionRules\" data-collection-name=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")") templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 102} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" x-init=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -92,7 +105,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><button @click=\"backToCollection()\" class=\"btn btn-secondary mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-6\"><button @click=\"backToCollection()\" class=\"btn btn-secondary mb-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -100,100 +113,100 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Back to Collection</button><div class=\"flex items-center gap-4\"><span class=\"grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl\" style=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Back to Collection</button><div class=\"flex items-center gap-4\"><span class=\"grid place-items-center h-12 w-12 rounded-2xl shrink-0 text-2xl\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 33, Col: 159}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 33, Col: 179} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 34, Col: 159}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 35, Col: 90} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 34, Col: 179}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><div><h1 class=\"text-3xl font-bold\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if scope == "episodes" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Episodes scope — membership recomputes live from the tree below.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Books scope — membership recomputes live from the tree below.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div></div><!-- The unified builder island: SAME component + vocabulary as\n\t\t\t\t the All-Books drawer (item 3.3's payload). Apply is\n\t\t\t\t collection-specific (writes the tree; the engine does the\n\t\t\t\t rest). The group-mode chips express ANY/ALL — the tree's\n\t\t\t\t root mode IS the collection's match semantics. --><div x-data=\"filterBuilderExtra\" data-filter-fields=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(fieldsJSON)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 54, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 36, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-scope=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if scope == "episodes" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Episodes scope — membership recomputes live from the tree below.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Books scope — membership recomputes live from the tree below.")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div></div></div><!-- The unified builder island: SAME component + vocabulary as\n\t\t\t\t the All-Books drawer (item 3.3's payload). Apply is\n\t\t\t\t collection-specific (writes the tree; the engine does the\n\t\t\t\t rest). The group-mode chips express ANY/ALL — the tree's\n\t\t\t\t root mode IS the collection's match semantics. --><div x-data=\"filterBuilderExtra\" data-filter-fields=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(scope) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(fieldsJSON))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-tree=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-scope=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(treeJSON)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(scope)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-tree=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(treeJSON))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 57, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -201,7 +214,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"mt-4 pt-4 border-t flex justify-end gap-2\" style=\"border-color: var(--border);\"><button type=\"button\" @click=\"backToCollection()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"applyCollectionTree()\" class=\"btn btn-primary\">Apply to Collection</button></div></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"mt-4 pt-4 border-t flex justify-end gap-2\" style=\"border-color: var(--border);\"><button type=\"button\" @click=\"backToCollection()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"applyCollectionTree()\" class=\"btn btn-primary\">Apply to Collection</button></div></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -30,7 +30,7 @@ func TestRenderingPartialsRender(t *testing.T) {
if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil { if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid empty: %v", err) t.Fatalf("CollectionBooksGrid empty: %v", err)
} }
if !strings.Contains(b.String(), "No books in this collection yet.") { if !strings.Contains(b.String(), "Nothing in this collection yet.") {
t.Error("books grid empty state missing") t.Error("books grid empty state missing")
} }
+9 -2
View File
@@ -24,11 +24,16 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi
showToast("Not authenticated", "error"); showToast("Not authenticated", "error");
return; return;
} }
const name = pageCollectionName();
if (!name) {
showToast("Cannot determine the collection name", "error");
return;
}
try { try {
const response = await fetch(`/api/collections/${collectionId}`, { const response = await fetch(`/api/collections/${collectionId}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: pageCollectionName(), filter_tree: JSON.parse(wire) }), body: JSON.stringify({ name, filter_tree: JSON.parse(wire) }),
}); });
if (response.ok) { if (response.ok) {
showToast("Smart collection updated", "success"); showToast("Smart collection updated", "success");
@@ -45,7 +50,9 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi
} }
function pageCollectionName(): string { function pageCollectionName(): string {
return document.title.split(" - ")[0] || "Collection"; // The server renders the real name (the page title is presentation,
// and parsing it would RENAME the collection to "Smart Collection").
return document.body.dataset.collectionName || "";
} }
function backToCollection(): void { function backToCollection(): void {