refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
504
modules/cabana/list_schema.go
Normal file
504
modules/cabana/list_schema.go
Normal file
@@ -0,0 +1,504 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const listSearchTerm = "search"
|
||||
|
||||
var listColumnTypes = map[string]struct{}{
|
||||
"text": {}, "datetime": {}, "switch": {},
|
||||
}
|
||||
|
||||
type listDocument struct {
|
||||
List string `yaml:"list"`
|
||||
ModelClass string `yaml:"modelClass"`
|
||||
Title string `yaml:"title"`
|
||||
RecordURL string `yaml:"recordUrl"`
|
||||
NoRecordsMessage string `yaml:"noRecordsMessage"`
|
||||
RecordsPerPage int `yaml:"recordsPerPage"`
|
||||
PerPageOptions []int `yaml:"perPageOptions"`
|
||||
ShowCheckboxes bool `yaml:"showCheckboxes"`
|
||||
ShowSetup bool `yaml:"showSetup"`
|
||||
ShowSorting *bool `yaml:"showSorting"`
|
||||
ShowSearch bool `yaml:"showSearch"`
|
||||
DefaultSort *listSortDocument `yaml:"defaultSort"`
|
||||
Toolbar *listToolbar `yaml:"toolbar"`
|
||||
Filter string `yaml:"filter"`
|
||||
Messages *listMessageKeys `yaml:"messages"`
|
||||
}
|
||||
|
||||
type listSortDocument struct {
|
||||
Column string `yaml:"column"`
|
||||
Direction string `yaml:"direction"`
|
||||
}
|
||||
|
||||
type listToolbar struct {
|
||||
Buttons toolbarButtons `yaml:"buttons"`
|
||||
Search *struct {
|
||||
Prompt string `yaml:"prompt"`
|
||||
Mode string `yaml:"mode"`
|
||||
} `yaml:"search"`
|
||||
}
|
||||
|
||||
type columnsDocument struct {
|
||||
Columns yaml.MapSlice `yaml:"columns"`
|
||||
}
|
||||
|
||||
type columnDocument struct {
|
||||
Label string `yaml:"label"`
|
||||
Searchable bool `yaml:"searchable"`
|
||||
Sortable *bool `yaml:"sortable"`
|
||||
Type string `yaml:"type"`
|
||||
Relation string `yaml:"relation"`
|
||||
Select string `yaml:"select"`
|
||||
}
|
||||
|
||||
// CompileListSchema compiles config_list.yaml and the columns.yaml it names.
|
||||
func CompileListSchema(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
|
||||
return compileList(pluginID, ctl, fsys)
|
||||
}
|
||||
|
||||
// CompileList compiles one controller's config_list.yaml and columns.yaml.
|
||||
func CompileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
|
||||
return compileList(pluginID, ctl, fsys)
|
||||
}
|
||||
|
||||
func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
|
||||
if ctl == nil {
|
||||
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
|
||||
}
|
||||
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
||||
if dir == "." || strings.HasPrefix(dir, "..") {
|
||||
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
|
||||
}
|
||||
cfgPath := path.Join(dir, "config_list.yaml")
|
||||
raw, err := readAsset(fsys, cfgPath)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
var doc listDocument
|
||||
if err := decodeStrict(raw, &doc); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
if doc.ModelClass != ctl.ModelName() {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
|
||||
}
|
||||
if strings.TrimSpace(doc.List) == "" {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("list file is empty"))
|
||||
}
|
||||
colPath, err := assetPath(pluginID, doc.List)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
columns, err := compileColumns(pluginID, ctl, fsys, colPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
per := doc.RecordsPerPage
|
||||
if per < 1 {
|
||||
per = 20
|
||||
}
|
||||
options, err := compilePageOptions(per, doc.PerPageOptions)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
showSearch := doc.ShowSearch
|
||||
prompt := ""
|
||||
if doc.Toolbar != nil && doc.Toolbar.Search != nil {
|
||||
showSearch = true
|
||||
prompt = doc.Toolbar.Search.Prompt
|
||||
if mode := doc.Toolbar.Search.Mode; mode != "" && mode != "all" && mode != "any" && mode != "exact" {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
|
||||
}
|
||||
}
|
||||
buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
sort, err := compileDefaultSort(doc.DefaultSort, columns)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||
}
|
||||
showSorting := true
|
||||
if doc.ShowSorting != nil {
|
||||
showSorting = *doc.ShowSorting
|
||||
}
|
||||
rowActions := []RowAction{}
|
||||
if doc.RecordURL != "" {
|
||||
rowActions = append(rowActions, RowAction{
|
||||
Name: "update",
|
||||
Label: "backend::lang.form.update",
|
||||
URL: doc.RecordURL,
|
||||
})
|
||||
}
|
||||
bulk := []BulkAction{}
|
||||
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
|
||||
}
|
||||
}
|
||||
schema := &ListSchema{
|
||||
Title: doc.Title,
|
||||
ModelClass: doc.ModelClass,
|
||||
RecordURL: doc.RecordURL,
|
||||
NoRecordsMessage: doc.NoRecordsMessage,
|
||||
RecordsPerPage: per,
|
||||
PerPageOptions: options,
|
||||
ShowSearch: showSearch,
|
||||
ShowSetup: doc.ShowSetup,
|
||||
ShowCheckboxes: doc.ShowCheckboxes,
|
||||
ShowSorting: showSorting,
|
||||
SearchTerm: listSearchTerm,
|
||||
SearchPrompt: prompt,
|
||||
DefaultSort: sort,
|
||||
ToolbarButtons: buttons,
|
||||
Columns: columns,
|
||||
Filters: filters,
|
||||
RowActions: rowActions,
|
||||
BulkActions: bulk,
|
||||
}
|
||||
if doc.Messages != nil {
|
||||
schema.messageKeys = *doc.Messages
|
||||
}
|
||||
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, schema.listMessageKeySet())
|
||||
schema.Messages = &keys
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) {
|
||||
colRaw, err := readAsset(fsys, colPath)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
|
||||
}
|
||||
var cols columnsDocument
|
||||
if err := decodeStrict(colRaw, &cols); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
|
||||
}
|
||||
modelCols, modelRels, checkModel := listModelContract(ctl)
|
||||
compiled := make([]ListColumn, 0, len(cols.Columns))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range cols.Columns {
|
||||
key, ok := item.Key.(string)
|
||||
if !ok || !identifier(key) {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column key %v is not an identifier", item.Key))
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("duplicate column %s", key))
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
encoded, err := yaml.Marshal(item.Value)
|
||||
if err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
|
||||
}
|
||||
var spec columnDocument
|
||||
if err := decodeStrict(encoded, &spec); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err))
|
||||
}
|
||||
if spec.Type != "" {
|
||||
if _, known := listColumnTypes[spec.Type]; !known {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", spec.Type))
|
||||
}
|
||||
}
|
||||
if spec.Relation != "" || spec.Select != "" {
|
||||
if !identifier(spec.Relation) {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("relation %q is not an identifier", spec.Relation))
|
||||
}
|
||||
if !identifier(spec.Select) {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("select %q is not an identifier", spec.Select))
|
||||
}
|
||||
if checkModel {
|
||||
if _, known := modelRels[spec.Relation]; !known {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("relation %s is not on the model", spec.Relation))
|
||||
}
|
||||
}
|
||||
} else if checkModel {
|
||||
if _, known := modelCols[key]; !known {
|
||||
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s is not on the model", key))
|
||||
}
|
||||
}
|
||||
sortable := spec.Relation == ""
|
||||
if spec.Sortable != nil {
|
||||
sortable = *spec.Sortable
|
||||
}
|
||||
compiled = append(compiled, ListColumn{
|
||||
Key: key,
|
||||
Label: spec.Label,
|
||||
Searchable: spec.Searchable,
|
||||
Sortable: sortable,
|
||||
Type: spec.Type,
|
||||
Relation: spec.Relation,
|
||||
Select: spec.Select,
|
||||
})
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
|
||||
if len(declared) == 0 {
|
||||
return []int{recordsPerPage}, nil
|
||||
}
|
||||
options := make([]int, len(declared))
|
||||
seen := map[int]struct{}{}
|
||||
found := false
|
||||
for i, n := range declared {
|
||||
if n < 1 {
|
||||
return nil, fmt.Errorf("perPageOptions must be positive")
|
||||
}
|
||||
if _, dup := seen[n]; dup {
|
||||
return nil, fmt.Errorf("duplicate perPageOptions %d", n)
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
options[i] = n
|
||||
if n == recordsPerPage {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("recordsPerPage %d is not in perPageOptions", recordsPerPage)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in
|
||||
// actions in display order. A scalar (Winter's partial name) is rejected
|
||||
// with a pointer at the list syntax.
|
||||
type toolbarButtons struct {
|
||||
items []string
|
||||
}
|
||||
|
||||
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
|
||||
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
|
||||
|
||||
func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
|
||||
node = unwrapNode(node)
|
||||
switch n := node.(type) {
|
||||
case nil, *ast.NullNode:
|
||||
b.items = nil
|
||||
return nil
|
||||
case *ast.SequenceNode:
|
||||
values := sequenceValues(n)
|
||||
items := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range values {
|
||||
action, err := nodeString(unwrapNode(item))
|
||||
if err != nil {
|
||||
return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)")
|
||||
}
|
||||
if _, ok := toolbarActions[action]; !ok {
|
||||
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
|
||||
}
|
||||
if _, dup := seen[action]; dup {
|
||||
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
|
||||
}
|
||||
seen[action] = struct{}{}
|
||||
items = append(items, action)
|
||||
}
|
||||
b.items = items
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node))
|
||||
}
|
||||
}
|
||||
|
||||
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
|
||||
if toolbar == nil || len(toolbar.Buttons.items) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
out := append([]string(nil), toolbar.Buttons.items...)
|
||||
for _, action := range out {
|
||||
if action == "delete" && !showCheckboxes {
|
||||
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
|
||||
if doc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var match *ListColumn
|
||||
for i := range columns {
|
||||
if columns[i].Key == doc.Column {
|
||||
match = &columns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
return nil, fmt.Errorf("default sort column %s is not a list column", doc.Column)
|
||||
}
|
||||
if !match.Sortable {
|
||||
return nil, fmt.Errorf("default sort column %s is not sortable", doc.Column)
|
||||
}
|
||||
if doc.Direction != "asc" && doc.Direction != "desc" {
|
||||
return nil, fmt.Errorf("default sort direction %s must be asc or desc", doc.Direction)
|
||||
}
|
||||
return &ListSort{Column: doc.Column, Direction: doc.Direction}, nil
|
||||
}
|
||||
|
||||
func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[string]struct{}, bool) {
|
||||
src, ok := ctl.(pact.AdminRecordSource)
|
||||
if !ok || src == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
model := src.NewRecord()
|
||||
if model == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
t := reflect.TypeOf(model)
|
||||
for t != nil && t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil, nil, false
|
||||
}
|
||||
cols := map[string]struct{}{}
|
||||
rels := map[string]struct{}{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
if isListRelation(field.Type) {
|
||||
rels[field.Name] = struct{}{}
|
||||
// Winter YAML spells relations in lowercase (relation: genre)
|
||||
// while the Go field is exported (Genre).
|
||||
rels[strings.ToLower(field.Name)] = struct{}{}
|
||||
continue
|
||||
}
|
||||
name := gormColumn(field)
|
||||
if name == "" {
|
||||
name = field.Name
|
||||
}
|
||||
cols[name] = struct{}{}
|
||||
}
|
||||
return cols, rels, true
|
||||
}
|
||||
|
||||
func isListRelation(t reflect.Type) bool {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() == reflect.Slice {
|
||||
t = t.Elem()
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return false
|
||||
}
|
||||
if t == reflect.TypeOf(time.Time{}) {
|
||||
return false
|
||||
}
|
||||
if t == reflect.TypeOf(gorm.DeletedAt{}) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
locale := schemaLocale(ctx, tr)
|
||||
ctx = towel.WithLocale(ctx, locale)
|
||||
out := *s
|
||||
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)
|
||||
messages := localizeMessages[listMessageKeys, ListMessages](ctx, tr, s.listMessageKeySet())
|
||||
out.Messages = &messages
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user