fix(collections): browser-verification pass over the unified builder/editor
The real-browser sweep (the gotcha classes' native habitat — CSP refusals
fail SILENTLY at build/test time) found four defects, all in the item
3.3/3.4 surfaces:
- Shared builder rows used Alpine attribute expressions that the CSP
parser rejects outright: backtick template literals in :style, the
?. optional chain in :placeholder/x-if, and an if-STATEMENT in a
@click.outside. Rewritten as string concat plus two component methods
(fieldIsDays, dismissSuggestions). Until this fix every drawer open
threw a wall of "CSP Parser Error" console noise and the days-value
affordance never rendered.
- "2 bookss match": the count noun is interpolated into "<n> <noun>s
match", so callers must pass the SINGULAR ("book"/"item").
- The rules editor page received its fields/tree payloads from the
route as already-JSON-encoded strings and wrapped them in JSONArg
AGAIN — double-encoded, so JSON.parse yields a string and the editor
loaded with NO vocabulary and NO prefill. The route now emits raw
JSON into the data attributes (single encode).
- Apply was silently inert: the hook store round-trip used
window.Alpine?.store (optional-chained, no-op when absent) and the
Cancel/Apply block sat outside the builder island scope for part of
the session. Both fixed via the imported Alpine instance and moving
the buttons inside the island; Apply now PUTs the tree end-to-end
(verified live: prefill → edit → apply → 201 → reload → membership).
Browser-verified clean (no fixes needed): picker scope tabs + episode
grid (?scope=episodes door), selection store across swaps, Add Selected
episode membership, library-switcher partial contract (episodes visible
under All Libraries, correctly filtered out under a book library — and
now never OFFERED as a switcher option), episode cards + reorder arrows
persisting position, collection-chain player (Ep N/M, prev/next carry
?queue=collection:, order control absent in collection mode and present
+ persistent in feed mode both in player and feed page), and the
empty-tree Apply guard (400 — a static collection can never be
promoted by accident).
Notes for the next reader of these lines: the OpenChamber panel's
browser.select sets select.value without dispatching change, so htmx
change-triggers must be exercised by real events (Playwright) rather
than the panel; and one 500 was observed on the player's leave-page
progress PUT — a request whose context the navigating browser cancels
mid-write, pre-existing and benign (the immediately following save
returned 200).
This commit is contained in:
1 parent
7bf5697c71
commit
94985adb5e
9 files changed
+49
-28
No files matched your search
@@ -722,7 +722,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fieldsJSON := templates.JSONArg(services.FilterFieldsPayload(scope))
|
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
||||||
|
if merr != nil {
|
||||||
|
fieldsPayload = []byte("[]")
|
||||||
|
}
|
||||||
|
fieldsJSON := string(fieldsPayload)
|
||||||
|
|
||||||
colData := templates.CollectionData{
|
colData := templates.CollectionData{
|
||||||
ID: collectionID,
|
ID: collectionID,
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ templ BookShelf(
|
|||||||
data-filter-fields={ JSONArg(services.FilterFieldsPayload("books")) }
|
data-filter-fields={ JSONArg(services.FilterFieldsPayload("books")) }
|
||||||
x-effect="filtersOpen === true && ensureReadyRule()"
|
x-effect="filtersOpen === true && ensureReadyRule()"
|
||||||
>
|
>
|
||||||
@FilterBuilderRows("books")
|
@FilterBuilderRows("book")
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = FilterBuilderRows("books").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = FilterBuilderRows("book").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
|
|||||||
class={ "theme-" + user.Theme }
|
class={ "theme-" + user.Theme }
|
||||||
x-data="collectionRules"
|
x-data="collectionRules"
|
||||||
data-collection-name={ collection.Name }
|
data-collection-name={ collection.Name }
|
||||||
x-init={ "initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")" }
|
x-init={ "initCollectionRules('" + collection.ID + "', '" + scope + "')" }
|
||||||
>
|
>
|
||||||
@Header(user, "/collections")
|
@Header(user, "/collections")
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -52,15 +52,15 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t
|
|||||||
root mode IS the collection's match semantics. -->
|
root mode IS the collection's match semantics. -->
|
||||||
<div
|
<div
|
||||||
x-data="filterBuilderExtra"
|
x-data="filterBuilderExtra"
|
||||||
data-filter-fields={ JSONArg(fieldsJSON) }
|
data-filter-fields={ fieldsJSON }
|
||||||
data-scope={ scope }
|
data-scope={ scope }
|
||||||
data-tree={ JSONArg(treeJSON) }
|
data-tree={ treeJSON }
|
||||||
class="card p-6"
|
class="card p-6"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3 px-1">
|
<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>
|
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Filter tree</h2>
|
||||||
</div>
|
</div>
|
||||||
@FilterBuilderRows("items")
|
@FilterBuilderRows("item")
|
||||||
<div class="mt-4 pt-4 border-t flex justify-end gap-2" style="border-color: var(--border);">
|
<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="backToCollection()" class="btn btn-secondary">Cancel</button>
|
||||||
<button type="button" @click="applyCollectionTree()" class="btn btn-primary">Apply to Collection</button>
|
<button type="button" @click="applyCollectionTree()" class="btn btn-primary">Apply to Collection</button>
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 102}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -172,9 +172,9 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
|
|||||||
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(JSONArg(fieldsJSON))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(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: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 36}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -187,7 +187,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(scope)
|
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: 24}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 23}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -198,9 +198,9 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(treeJSON))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(treeJSON)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 57, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 57, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -210,7 +210,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 = FilterBuilderRows("items").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = FilterBuilderRows("item").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ templ FilterBuilderRows(countNoun string) {
|
|||||||
<template x-for="row in builderRows" :key="row.key">
|
<template x-for="row in builderRows" :key="row.key">
|
||||||
<div>
|
<div>
|
||||||
<template x-if="row.type === 'groupOpen'">
|
<template x-if="row.type === 'groupOpen'">
|
||||||
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="`margin-left:${row.depth * 14}px`">
|
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="'margin-left:' + (row.depth * 14) + 'px'">
|
||||||
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
|
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
|
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
|
||||||
@@ -19,7 +19,7 @@ templ FilterBuilderRows(countNoun string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="row.type === 'rule'">
|
<template x-if="row.type === 'rule'">
|
||||||
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="`margin-left:${(row.depth + 1) * 14}px`">
|
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="'margin-left:' + ((row.depth + 1) * 14) + 'px'">
|
||||||
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
|
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
|
||||||
<!-- field picker: grouped menu -->
|
<!-- field picker: grouped menu -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -60,8 +60,8 @@ templ FilterBuilderRows(countNoun string) {
|
|||||||
</template>
|
</template>
|
||||||
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
|
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
|
||||||
<span class="fb-value-cell relative flex items-center gap-1">
|
<span class="fb-value-cell relative flex items-center gap-1">
|
||||||
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/>
|
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldIsDays(row.rule.field) ? 'days' : 'value'"/>
|
||||||
<template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'">
|
<template x-if="fieldIsDays(row.rule.field)">
|
||||||
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
|
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
|
||||||
</template>
|
</template>
|
||||||
<!-- field-aware value menu: server JSON (the other
|
<!-- field-aware value menu: server JSON (the other
|
||||||
@@ -69,7 +69,7 @@ templ FilterBuilderRows(countNoun string) {
|
|||||||
Clicks anywhere inside the value cell never close
|
Clicks anywhere inside the value cell never close
|
||||||
it — the opening focus-click must not race the
|
it — the opening focus-click must not race the
|
||||||
outside handler. -->
|
outside handler. -->
|
||||||
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''">
|
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="dismissSuggestions($event)">
|
||||||
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
|
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
|
||||||
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
|
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
|
||||||
<span x-text="s.display || s.value"></span>
|
<span x-text="s.display || s.value"></span>
|
||||||
@@ -86,7 +86,7 @@ templ FilterBuilderRows(countNoun string) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="row.type === 'groupClose' && row.depth > 0">
|
<template x-if="row.type === 'groupClose' && row.depth > 0">
|
||||||
<div class="fb-group-end" :style="`margin-left:${row.depth * 14}px`"></div>
|
<div class="fb-group-end" :style="'margin-left:' + (row.depth * 14) + 'px'"></div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func FilterBuilderRows(countNoun string) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''\"><template x-for=\"s in (suggestions[row.key] || [])\" Line truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldIsDays(row.rule.field) ? 'days' : 'value'\"><template x-if=\"fieldIsDays(row.rule.field)\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"dismissSuggestions($event)\"><template x-for=\"s in (suggestions[row.key] || [])\" :key=\"s.value\"><button type=\"button\" class=\"fb-menu-item\" @cLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi
|
|||||||
collectionId = id;
|
collectionId = id;
|
||||||
|
|
||||||
// The builder island emits its APPLY wire through this hook — the
|
// The builder island emits its APPLY wire through this hook — the
|
||||||
// page (not the shared component) owns the collection write.
|
// page (not the shared component) owns the collection write. The
|
||||||
window.Alpine?.store("collectionTreeApply", {
|
// IMPORTED Alpine instance owns the store registry (the bookPicker
|
||||||
|
// pattern); window.Alpine is optional-chained elsewhere and may be
|
||||||
|
// absent at init timing.
|
||||||
|
Alpine.store("collectionTreeApply", {
|
||||||
hook: async (wire: string) => {
|
hook: async (wire: string) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -393,10 +393,10 @@ function filterBuilderData() {
|
|||||||
// applyCollectionTree delegates to the page-registered hook (the
|
// applyCollectionTree delegates to the page-registered hook (the
|
||||||
// collection editor PUTs the tree; All-Books never sets one).
|
// collection editor PUTs the tree; All-Books never sets one).
|
||||||
applyCollectionTree(): void {
|
applyCollectionTree(): void {
|
||||||
const hook = (
|
const store = Alpine.store("collectionTreeApply") as
|
||||||
window.Alpine?.store("collectionTreeApply") as { hook?: (wire: string) => void } | undefined
|
| { hook?: (wire: string) => void }
|
||||||
)?.hook;
|
| undefined;
|
||||||
if (hook) hook(this.treeJSON());
|
if (store?.hook) store.hook(this.treeJSON());
|
||||||
},
|
},
|
||||||
// Rule tree (source of truth; hidden wire inputs are synced after
|
// Rule tree (source of truth; hidden wire inputs are synced after
|
||||||
// every mutation).
|
// every mutation).
|
||||||
@@ -426,6 +426,20 @@ function filterBuilderData() {
|
|||||||
fieldMeta(key: string): FieldMeta | undefined {
|
fieldMeta(key: string): FieldMeta | undefined {
|
||||||
return FIELD_BY_KEY.get(key);
|
return FIELD_BY_KEY.get(key);
|
||||||
},
|
},
|
||||||
|
// CSP-safe accessor: attribute expressions cannot use optional
|
||||||
|
// chaining (the CSP parser rejects `?.`), so the null-walk lives
|
||||||
|
// here.
|
||||||
|
fieldIsDays(key: string): boolean {
|
||||||
|
return this.fieldMeta(key)?.valueKind === "days";
|
||||||
|
},
|
||||||
|
// CSP-safe click-outside for the value menu (attribute expressions
|
||||||
|
// cannot carry if-statements).
|
||||||
|
dismissSuggestions(e: Event): void {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (!target || !target.closest(".fb-value-cell")) {
|
||||||
|
this.suggestionsOpen = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
fieldLabelOf(key: string): string {
|
fieldLabelOf(key: string): string {
|
||||||
return FIELD_BY_KEY.get(key)?.label ?? "Field";
|
return FIELD_BY_KEY.get(key)?.label ?? "Field";
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user