feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)

The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:22:17 -04:00
1 parent eb68672390
commit 4f71eda6be
16 files changed
+518 -1422

No files matched your search

+42 -49
View File
@@ -8,7 +8,14 @@ package templates
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func CollectionRules(user User, collection CollectionData) templ.Component {
// CollectionRules is the SMART-collection editor (plan item 3.4): the
// unified filter builder in the collection's scope. One vocabulary
// (services.FilterFieldsPayload — the same wire the All-Books builder
// renders), one engine: Apply writes the tree to the collection and
// the qf engine drives membership everywhere. Legacy rules arrays
// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade
// happens at the first Apply, never silently at load.
func CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,14 +36,14 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Collection Rules - ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Smart Collection - ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 16, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -69,9 +76,9 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + collection.MatchMode + "')")
templ_7745c5c3_Var5, 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: 13, Col: 152}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -100,7 +107,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
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: 22, Col: 159}
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 {
@@ -113,7 +120,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 179}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 33, Col: 179}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -126,89 +133,75 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 35, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
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> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><div class=\"flex flex-wrap items-center justify-between gap-3 mb-4\"><h2 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">Rules</h2><!-- Match semantics (plan item 2.4): ANY = OR (legacy),\n\t\t\t\t\t\t ALL = AND (the daily briefing). Own channel — writes\n\t\t\t\t\t\t only rule_match_mode. --><div class=\"flex items-center gap-2\"><span class=\"text-sm\" style=\"color: var(--text-secondary)\">Match</span> <button type=\"button\" @click=\"setMatchMode('any')\" :class=\"")
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> ")
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'any' ? 'btn btn-secondary' : 'btn btn-ghost'")
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(fieldsJSON))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 42, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 54, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"btn\">ANY (or)</button> <button type=\"button\" @click=\"setMatchMode('all')\" :class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-scope=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'all' ? 'btn btn-secondary' : 'btn btn-ghost'")
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(scope)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 46, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"btn\">ALL (and)</button></div></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">An item is in the collection while it matches <span class=\"font-semibold\" x-text=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-tree=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'all' ? 'ALL of' : 'ANY of'")
templ_7745c5c3_Var11, 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: 51, Col: 132}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">ANY of</span> the enabled rules. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("filter", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = FilterBuilderRows("items").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 66, Col: 67}
}
_, 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, 15, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option> <option value=\"title\">Title</option> <option value=\"description\">Description</option> <optgroup label=\"Podcast episodes\"><option value=\"feed\">Feed (IDs, comma-separated)</option> <option value=\"played\">Played (true/false)</option> <option value=\"downloaded\">Downloaded (true/false)</option> <option value=\"published_within\">Published within (days)</option></optgroup></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option> <option value=\"in\">is one of (comma-separated)</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}