feat(09-04): compile switch, date-range, and scope filters
- Filters keep typed values and only registered scope names - Raw conditions and arbitrary methods fail activation - Request localization copies labels and leaves identifiers unchanged
This commit is contained in:
304
cabana/filter_schema.go
Normal file
304
cabana/filter_schema.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
|
||||
var filterScopeKeys = map[string]struct{}{
|
||||
"label": {}, "type": {}, "column": {}, "options": {},
|
||||
"modelClass": {}, "nameFrom": {}, "scope": {},
|
||||
}
|
||||
|
||||
type filterDocument struct {
|
||||
Scopes scopeMap `yaml:"scopes"`
|
||||
}
|
||||
|
||||
type scopeMap struct {
|
||||
items []ListFilter
|
||||
}
|
||||
|
||||
func compileFilters(pluginID string, ctl pact.AdminController, fsys fs.FS, dir, ref string) ([]ListFilter, error) {
|
||||
file, err := filterAssetPath(pluginID, dir, ref)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), "config_filter.yaml", err)
|
||||
}
|
||||
raw, err := readAsset(fsys, file)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), file, err)
|
||||
}
|
||||
var doc filterDocument
|
||||
if err := decodeStrict(raw, &doc); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), file, err)
|
||||
}
|
||||
filters := doc.Scopes.items
|
||||
if filters == nil {
|
||||
filters = []ListFilter{}
|
||||
}
|
||||
provider := filterProvider(ctl)
|
||||
for _, filter := range filters {
|
||||
if err := validateFilter(ctl, provider, filter); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), file, err)
|
||||
}
|
||||
}
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func filterAssetPath(pluginID, dir, ref string) (string, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("filter file is empty")
|
||||
}
|
||||
if strings.Contains(ref, "..") {
|
||||
return "", fmt.Errorf("filter path escapes the plugin")
|
||||
}
|
||||
if strings.HasPrefix(ref, "~/") || strings.Contains(ref, "/") {
|
||||
return assetPath(pluginID, ref)
|
||||
}
|
||||
return path.Join(dir, ref), nil
|
||||
}
|
||||
|
||||
func (m *scopeMap) UnmarshalYAML(node ast.Node) error {
|
||||
node = unwrapNode(node)
|
||||
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
||||
m.items = []ListFilter{}
|
||||
return nil
|
||||
}
|
||||
mapping, ok := node.(*ast.MappingNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("scopes must be a mapping")
|
||||
}
|
||||
items := make([]ListFilter, 0, len(mapping.Values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, entry := range mapping.Values {
|
||||
name, err := nodeString(unwrapNode(entry.Key))
|
||||
if err != nil || !identifier(name) {
|
||||
return fmt.Errorf("filter name %q is not an identifier", nodeText(entry.Key))
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
return fmt.Errorf("duplicate filter %s", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
filter, err := compileScopeNode(name, unwrapNode(entry.Value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter %s: %w", name, err)
|
||||
}
|
||||
items = append(items, filter)
|
||||
}
|
||||
m.items = items
|
||||
return nil
|
||||
}
|
||||
|
||||
func compileScopeNode(name string, node ast.Node) (ListFilter, error) {
|
||||
mapping, ok := node.(*ast.MappingNode)
|
||||
if !ok {
|
||||
return ListFilter{}, fmt.Errorf("must be a mapping")
|
||||
}
|
||||
values := map[string]ast.Node{}
|
||||
for _, entry := range mapping.Values {
|
||||
key, err := nodeString(unwrapNode(entry.Key))
|
||||
if err != nil {
|
||||
return ListFilter{}, err
|
||||
}
|
||||
if key == "conditions" {
|
||||
return ListFilter{}, fmt.Errorf("conditions is not supported; use a column or a registered model scope")
|
||||
}
|
||||
if _, known := filterScopeKeys[key]; !known {
|
||||
return ListFilter{}, fmt.Errorf("unknown field %s", key)
|
||||
}
|
||||
if _, dup := values[key]; dup {
|
||||
return ListFilter{}, fmt.Errorf("duplicate field %s", key)
|
||||
}
|
||||
values[key] = unwrapNode(entry.Value)
|
||||
}
|
||||
filter := ListFilter{Name: name}
|
||||
var err error
|
||||
if node, ok := values["label"]; ok {
|
||||
filter.Label, err = nodeString(node)
|
||||
if err != nil {
|
||||
return ListFilter{}, fmt.Errorf("label: %w", err)
|
||||
}
|
||||
}
|
||||
if node, ok := values["type"]; ok {
|
||||
filter.Type, err = nodeString(node)
|
||||
if err != nil {
|
||||
return ListFilter{}, fmt.Errorf("type: %w", err)
|
||||
}
|
||||
}
|
||||
if node, ok := values["column"]; ok {
|
||||
filter.Column, err = nodeString(node)
|
||||
if err != nil || !identifier(filter.Column) {
|
||||
return ListFilter{}, fmt.Errorf("column %q is not an identifier", filter.Column)
|
||||
}
|
||||
}
|
||||
if node, ok := values["modelClass"]; ok {
|
||||
filter.ModelClass, err = nodeString(node)
|
||||
if err != nil || strings.TrimSpace(filter.ModelClass) == "" {
|
||||
return ListFilter{}, fmt.Errorf("modelClass is empty")
|
||||
}
|
||||
}
|
||||
if node, ok := values["nameFrom"]; ok {
|
||||
filter.NameFrom, err = nodeString(node)
|
||||
if err != nil || !identifier(filter.NameFrom) {
|
||||
return ListFilter{}, fmt.Errorf("nameFrom %q is not an identifier", filter.NameFrom)
|
||||
}
|
||||
}
|
||||
if node, ok := values["scope"]; ok {
|
||||
filter.Scope, err = nodeString(node)
|
||||
if err != nil || !identifier(filter.Scope) {
|
||||
return ListFilter{}, fmt.Errorf("scope %q is not an identifier", filter.Scope)
|
||||
}
|
||||
}
|
||||
if node, ok := values["options"]; ok {
|
||||
filter.Options, err = compileFilterOptions(node)
|
||||
if err != nil {
|
||||
return ListFilter{}, fmt.Errorf("options: %w", err)
|
||||
}
|
||||
}
|
||||
if filter.Type == "" && filter.Scope != "" {
|
||||
filter.Type = "scope"
|
||||
}
|
||||
switch filter.Type {
|
||||
case "switch":
|
||||
if filter.Column == "" {
|
||||
return ListFilter{}, fmt.Errorf("column is required")
|
||||
}
|
||||
if len(filter.Options) == 0 {
|
||||
on := jsonScalar{raw: []byte("true")}
|
||||
off := jsonScalar{raw: []byte("false")}
|
||||
filter.TrueValue = &on
|
||||
filter.FalseValue = &off
|
||||
}
|
||||
case "daterange":
|
||||
if filter.Column == "" {
|
||||
return ListFilter{}, fmt.Errorf("column is required")
|
||||
}
|
||||
case "scope":
|
||||
if filter.Scope == "" || filter.ModelClass == "" || filter.NameFrom == "" {
|
||||
return ListFilter{}, fmt.Errorf("scope, modelClass, and nameFrom are required")
|
||||
}
|
||||
default:
|
||||
if filter.Type == "" {
|
||||
return ListFilter{}, fmt.Errorf("type is required")
|
||||
}
|
||||
return ListFilter{}, fmt.Errorf("unsupported filter type %s", filter.Type)
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func compileFilterOptions(node ast.Node) ([]FormOption, error) {
|
||||
mapping, ok := node.(*ast.MappingNode)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("must be a map")
|
||||
}
|
||||
options := make([]FormOption, 0, len(mapping.Values))
|
||||
for _, entry := range mapping.Values {
|
||||
value, err := nodeScalar(unwrapNode(entry.Key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("value: %w", err)
|
||||
}
|
||||
label, err := nodeString(unwrapNode(entry.Value))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("label: %w", err)
|
||||
}
|
||||
options = append(options, FormOption{Value: value, Label: label})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter ListFilter) error {
|
||||
switch filter.Type {
|
||||
case "switch", "daterange":
|
||||
if _, _, check := listModelContract(ctl); check {
|
||||
ft, ok := modelColumnType(ctl, filter.Column)
|
||||
if !ok {
|
||||
return fmt.Errorf("column %s is not on the model", filter.Column)
|
||||
}
|
||||
if filter.Type == "switch" && !isBoolType(ft) {
|
||||
return fmt.Errorf("column %s is not a boolean", filter.Column)
|
||||
}
|
||||
if filter.Type == "daterange" && !isTimeType(ft) {
|
||||
return fmt.Errorf("column %s is not a date", filter.Column)
|
||||
}
|
||||
}
|
||||
case "scope":
|
||||
if provider == nil {
|
||||
return fmt.Errorf("scope %s requires FilterScope", filter.Scope)
|
||||
}
|
||||
for _, name := range provider.FilterScopes() {
|
||||
if name == filter.Scope {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("scope %s is not registered", filter.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterProvider(ctl pact.AdminController) pact.FilterScope {
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil {
|
||||
return nil
|
||||
}
|
||||
rec := src.NewRecord()
|
||||
provider, ok := rec.(pact.FilterScope)
|
||||
if !ok || provider == nil {
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func modelColumnType(ctl pact.AdminController, column string) (reflect.Type, bool) {
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil || src.NewRecord() == nil {
|
||||
return nil, false
|
||||
}
|
||||
t := reflect.TypeOf(src.NewRecord())
|
||||
for t != nil && t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil, false
|
||||
}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath != "" || isListRelation(field.Type) {
|
||||
continue
|
||||
}
|
||||
name := gormColumn(field)
|
||||
if name == "" {
|
||||
name = field.Name
|
||||
}
|
||||
if name == column {
|
||||
return field.Type, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func isTimeType(t reflect.Type) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t == reflect.TypeOf(time.Time{})
|
||||
}
|
||||
|
||||
func isBoolType(t reflect.Type) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t.Kind() == reflect.Bool
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
"git.golem15.com/golem15/summercms/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -140,10 +141,30 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
||||
if schema == nil {
|
||||
schema = &ListSchema{}
|
||||
}
|
||||
WriteData(w, http.StatusOK, schema, map[string]any{})
|
||||
view, err := schema.Localize(r.Context(), s.translator())
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
meta := map[string]any{}
|
||||
if view.Meta != nil {
|
||||
meta["locale"] = view.Meta.Locale
|
||||
}
|
||||
WriteData(w, http.StatusOK, view, meta)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) translator() *phrasebook.Translator {
|
||||
if s == nil || s.app == nil {
|
||||
return nil
|
||||
}
|
||||
tr, ok := s.app.Lookup[*phrasebook.Translator]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
db, err := s.db()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/towel"
|
||||
"github.com/goccy/go-yaml"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -35,6 +36,7 @@ type listDocument struct {
|
||||
ShowSearch bool `yaml:"showSearch"`
|
||||
DefaultSort *listSortDocument `yaml:"defaultSort"`
|
||||
Toolbar *listToolbar `yaml:"toolbar"`
|
||||
Filter string `yaml:"filter"`
|
||||
}
|
||||
|
||||
type listSortDocument struct {
|
||||
@@ -145,6 +147,13 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
|
||||
if doc.ShowCheckboxes {
|
||||
bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"})
|
||||
}
|
||||
filters := []ListFilter{}
|
||||
if strings.TrimSpace(doc.Filter) != "" {
|
||||
filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ListSchema{
|
||||
Title: doc.Title,
|
||||
ModelClass: doc.ModelClass,
|
||||
@@ -161,7 +170,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
|
||||
DefaultSort: sort,
|
||||
ToolbarButtons: buttons,
|
||||
Columns: columns,
|
||||
Filters: []ListFilter{},
|
||||
Filters: filters,
|
||||
RowActions: rowActions,
|
||||
BulkActions: bulk,
|
||||
}, nil
|
||||
@@ -354,8 +363,8 @@ func isListRelation(t reflect.Type) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Localize copies the cached list schema for one request.
|
||||
// The RED seam returns source keys; request-time translation replaces this body.
|
||||
// Localize copies the cached list schema and resolves display strings for one request.
|
||||
// Column, filter, and scope identifiers and option values are not translated.
|
||||
func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*ListSchema, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("cabana: nil list schema")
|
||||
@@ -363,7 +372,78 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
locale := schemaLocale(ctx, tr)
|
||||
ctx = towel.WithLocale(ctx, locale)
|
||||
out := *s
|
||||
_ = tr
|
||||
out.Title = translateKey(ctx, tr, s.Title)
|
||||
out.NoRecordsMessage = translateKey(ctx, tr, s.NoRecordsMessage)
|
||||
out.SearchPrompt = translateKey(ctx, tr, s.SearchPrompt)
|
||||
out.PerPageOptions = append([]int(nil), s.PerPageOptions...)
|
||||
out.ToolbarButtons = append([]string(nil), s.ToolbarButtons...)
|
||||
if s.DefaultSort != nil {
|
||||
sort := *s.DefaultSort
|
||||
out.DefaultSort = &sort
|
||||
}
|
||||
out.Columns = localizeListColumns(ctx, tr, s.Columns)
|
||||
out.Filters = localizeListFilters(ctx, tr, s.Filters)
|
||||
out.RowActions = localizeRowActions(ctx, tr, s.RowActions)
|
||||
out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions)
|
||||
out.Meta = &FormMeta{Locale: locale}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func localizeListColumns(ctx context.Context, tr *phrasebook.Translator, cols []ListColumn) []ListColumn {
|
||||
out := make([]ListColumn, len(cols))
|
||||
for i, col := range cols {
|
||||
col.Label = translateKey(ctx, tr, col.Label)
|
||||
out[i] = col
|
||||
}
|
||||
if out == nil {
|
||||
out = []ListColumn{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localizeListFilters(ctx context.Context, tr *phrasebook.Translator, filters []ListFilter) []ListFilter {
|
||||
out := make([]ListFilter, len(filters))
|
||||
for i, filter := range filters {
|
||||
filter.Label = translateKey(ctx, tr, filter.Label)
|
||||
if len(filter.Options) > 0 {
|
||||
opts := make([]FormOption, len(filter.Options))
|
||||
for j, opt := range filter.Options {
|
||||
opt.Label = translateKey(ctx, tr, opt.Label)
|
||||
opts[j] = opt
|
||||
}
|
||||
filter.Options = opts
|
||||
}
|
||||
out[i] = filter
|
||||
}
|
||||
if out == nil {
|
||||
out = []ListFilter{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localizeRowActions(ctx context.Context, tr *phrasebook.Translator, actions []RowAction) []RowAction {
|
||||
out := make([]RowAction, len(actions))
|
||||
for i, action := range actions {
|
||||
action.Label = translateKey(ctx, tr, action.Label)
|
||||
out[i] = action
|
||||
}
|
||||
if out == nil {
|
||||
out = []RowAction{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localizeBulkActions(ctx context.Context, tr *phrasebook.Translator, actions []BulkAction) []BulkAction {
|
||||
out := make([]BulkAction, len(actions))
|
||||
for i, action := range actions {
|
||||
action.Label = translateKey(ctx, tr, action.Label)
|
||||
out[i] = action
|
||||
}
|
||||
if out == nil {
|
||||
out = []BulkAction{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ type ListFilter struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ModelClass string `json:"modelClass,omitempty"`
|
||||
NameFrom string `json:"nameFrom,omitempty"`
|
||||
TrueValue *jsonScalar `json:"trueValue,omitempty"`
|
||||
FalseValue *jsonScalar `json:"falseValue,omitempty"`
|
||||
Options []FormOption `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
@@ -66,6 +68,7 @@ type ListSchema struct {
|
||||
Filters []ListFilter `json:"filters"`
|
||||
RowActions []RowAction `json:"rowActions"`
|
||||
BulkActions []BulkAction `json:"bulkActions"`
|
||||
Meta *FormMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
|
||||
|
||||
@@ -217,8 +217,11 @@ type RelationBeforeLink interface {
|
||||
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
|
||||
}
|
||||
|
||||
// FilterScope is a model method referenced by a config_filter scope name.
|
||||
// FilterScope is the model capability a config_filter scope name may call.
|
||||
// FilterScopes is the exact, case-sensitive set of names; request text never
|
||||
// selects a method outside that set.
|
||||
type FilterScope interface {
|
||||
FilterScopes() []string
|
||||
FilterScope(name string, db *gorm.DB, value any) *gorm.DB
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user