feat(filters): one vocabulary, server-rendered — the TS mirror retires (item 3.3)
"One filter system to maintain" now holds at the presentation layer too: the field/op list the builder renders comes from services.FilterFieldsPayload(scope) — the Go registry's wire face, scope-filtered and group-ordered — rendered into the bookshelf mount as a data attribute (templates.JSONArg), ingested once by the Alpine component. The registry carries operator presentation (labels, negation, arity) in FilterOpWire alongside the grammar; per-field value-cell semantics (valueKind) derive from the existing Kind. The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS, FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts now ingests the wire (ingestFilterFields — malformed payloads leave empty menus rather than client-invented rows), and the field menu's episode fields are HIDDEN on the All-Books page for free (the books scope payload excludes them — the plan's "hide podcasts from the rules on the all books page" is now structural, not enforced by a second hand-kept list). Unit tests ingest a minimal wire fixture (the payload shape is the contract, not the mirror); vitest 167 green, tsc + vp clean.
This commit is contained in:
1 parent
6b2125a45a
commit
eb68672390
5 files changed
+345
-341
No files matched your search
@@ -186,6 +186,128 @@ func (f FilterField) InScope(scope string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterOpWire is one operator's presentation contract (item 3.3):
|
||||
// THE system carries both grammar and rendering in one place — the
|
||||
// frontend renders this wire, it invents nothing.
|
||||
type FilterOpWire struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Negated bool `json:"negated,omitempty"`
|
||||
// Values is the value-cell arity: "many" (one multi-value input),
|
||||
// "one", "two", or "none".
|
||||
Values string `json:"values"`
|
||||
}
|
||||
|
||||
var filterOps = map[string]FilterOpWire{
|
||||
OpContains: {Key: OpContains, Label: "contains", Values: "many"},
|
||||
OpNotContains: {Key: OpNotContains, Label: "doesn't contain", Negated: true, Values: "many"},
|
||||
OpIs: {Key: OpIs, Label: "is", Values: "many"},
|
||||
OpIsNot: {Key: OpIsNot, Label: "isn't", Negated: true, Values: "many"},
|
||||
OpEmpty: {Key: OpEmpty, Label: "is empty", Values: "none"},
|
||||
OpIsTrue: {Key: OpIsTrue, Label: "is true", Values: "none"},
|
||||
OpIsFalse: {Key: OpIsFalse, Label: "is false", Values: "none"},
|
||||
OpBetween: {Key: OpBetween, Label: "between", Values: "two"},
|
||||
OpAtLeast: {Key: OpAtLeast, Label: "at least", Values: "one"},
|
||||
OpAtMost: {Key: OpAtMost, Label: "at most", Values: "one"},
|
||||
OpWithinDays: {Key: OpWithinDays, Label: "within the last", Values: "one"},
|
||||
OpHas: {Key: OpHas, Label: "has", Values: "none"},
|
||||
OpMissing: {Key: OpMissing, Label: "missing", Values: "none"},
|
||||
OpUnrated: {Key: OpUnrated, Label: "unrated", Values: "none"},
|
||||
OpIn: {Key: OpIn, Label: "in", Values: "many"},
|
||||
OpNotIn: {Key: OpNotIn, Label: "isn't in", Negated: true, Values: "many"},
|
||||
OpInAny: {Key: OpInAny, Label: "in any", Values: "none"},
|
||||
OpInNone: {Key: OpInNone, Label: "in none", Negated: true, Values: "none"},
|
||||
}
|
||||
|
||||
// FilterFieldWire is one field's presentation entry (the registry's
|
||||
// wire face): the frontend menu is scope-filtered OFF this list.
|
||||
type FilterFieldWire struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Group string `json:"group"`
|
||||
ValueKind string `json:"valueKind"`
|
||||
Ops []FilterOpWire `json:"ops"`
|
||||
DefaultOp string `json:"defaultOp"`
|
||||
Suggest bool `json:"suggest"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// valueKinds — what the value cell renders (the registry's Kind mapped
|
||||
// to frontend-cell semantics).
|
||||
const (
|
||||
VKText = "text"
|
||||
VKNumber = "number"
|
||||
VKDays = "days"
|
||||
VKId = "id"
|
||||
VKFixed = "fixed"
|
||||
VKNone = "none"
|
||||
)
|
||||
|
||||
// valueKindFor maps the SQL-facing Kind to the cell semantics.
|
||||
func valueKindFor(kind FilterKind, key string) string {
|
||||
switch kind {
|
||||
case KindScalarFuzzy, KindArrayFuzzy, KindText:
|
||||
return VKText
|
||||
case KindEnum, KindStatus, KindLibrary, KindCollection, KindIdentifier:
|
||||
return VKId
|
||||
case KindBool:
|
||||
return VKNone
|
||||
case KindNumber, KindDate:
|
||||
if kind == KindDate {
|
||||
return VKDays
|
||||
}
|
||||
return VKNumber
|
||||
case KindPresence, KindNoteText:
|
||||
return VKNone
|
||||
}
|
||||
if key == "added_by" {
|
||||
return VKId
|
||||
}
|
||||
return VKNone
|
||||
}
|
||||
|
||||
// FilterFieldsPayload is THE builder vocabulary for one scope: group-
|
||||
// ordered wire fields (registry order), each with its ops' presentation.
|
||||
// The frontend renders this; it hardcodes nothing.
|
||||
func FilterFieldsPayload(scope string) []FilterFieldWire {
|
||||
suggestKnown := map[SuggestSource]bool{
|
||||
suggestAuthor: true, suggestNarrator: true, suggestContrib: true,
|
||||
suggestPublisher: true, suggestTags: true, suggestColumn: true,
|
||||
suggestSeries: true, suggestLibrary: true, suggestCollection: true,
|
||||
suggestAddedBy: true, suggestFeed: true,
|
||||
}
|
||||
presented := make(map[string]bool)
|
||||
out := []FilterFieldWire{}
|
||||
for _, f := range filterFields {
|
||||
if !f.InScope(scope) {
|
||||
continue
|
||||
}
|
||||
if presented[f.Key] {
|
||||
continue
|
||||
}
|
||||
presented[f.Key] = true
|
||||
ops := make([]FilterOpWire, 0, len(f.Ops))
|
||||
for _, opKey := range f.Ops {
|
||||
ops = append(ops, filterOps[opKey])
|
||||
}
|
||||
wire := FilterFieldWire{
|
||||
Key: f.Key,
|
||||
Label: f.Label,
|
||||
Group: f.Group,
|
||||
ValueKind: valueKindFor(f.Kind, f.Key),
|
||||
Ops: ops,
|
||||
DefaultOp: f.DefaultOp,
|
||||
Suggest: suggestKnown[f.Suggest],
|
||||
}
|
||||
if domain, ok := fixedDomains[f.Key]; ok {
|
||||
wire.ValueKind = VKFixed
|
||||
wire.Options = domain
|
||||
}
|
||||
out = append(out, wire)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opsWithValue / opsWithoutValue drive validation.
|
||||
var opsNoValue = map[string]bool{
|
||||
OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,
|
||||
|
||||
Reference in new issue
Block a user