feat(09-04): compile ordered Winter list schemas
- Typed columns, actions, default sort, search term, and page sizes - Omitted sortable defaults to true and empty collections marshal as arrays - Unknown keys, bad defaults, and path escape fail before routes are served
This commit is contained in:
@@ -56,23 +56,6 @@ func (BackendUser) TableName() string { return "backend_users" }
|
|||||||
// Option is a dropdown choice shared with later schema plans.
|
// Option is a dropdown choice shared with later schema plans.
|
||||||
type Option = pact.Option
|
type Option = pact.Option
|
||||||
|
|
||||||
// ListColumn is one compiled columns.yaml entry, in file order.
|
|
||||||
type ListColumn struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Searchable bool `json:"searchable"`
|
|
||||||
Sortable bool `json:"sortable"`
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSchema is the boot-compiled list contract for one controller.
|
|
||||||
type ListSchema struct {
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
RecordsPerPage int `json:"recordsPerPage"`
|
|
||||||
ShowSearch bool `json:"showSearch"`
|
|
||||||
Columns []ListColumn `json:"columns"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompiledController is one admin controller after YAML compilation.
|
// CompiledController is one admin controller after YAML compilation.
|
||||||
type CompiledController struct {
|
type CompiledController struct {
|
||||||
PluginID string
|
PluginID string
|
||||||
|
|||||||
@@ -136,16 +136,11 @@ func constrainController(g pact.Router) {
|
|||||||
|
|
||||||
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
s.protect(w, r, func(cc *CompiledController) {
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
cols := cc.List.Columns
|
schema := cc.List
|
||||||
if cols == nil {
|
if schema == nil {
|
||||||
cols = []ListColumn{}
|
schema = &ListSchema{}
|
||||||
}
|
}
|
||||||
WriteData(w, http.StatusOK, &ListSchema{
|
WriteData(w, http.StatusOK, schema, map[string]any{})
|
||||||
Title: cc.List.Title,
|
|
||||||
RecordsPerPage: cc.List.RecordsPerPage,
|
|
||||||
ShowSearch: cc.List.ShowSearch,
|
|
||||||
Columns: cols,
|
|
||||||
}, map[string]any{})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
353
cabana/list_schema.go
Normal file
353
cabana/list_schema.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listSortDocument struct {
|
||||||
|
Column string `yaml:"column"`
|
||||||
|
Direction string `yaml:"direction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listToolbar struct {
|
||||||
|
Buttons string `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)
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
return &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: []ListFilter{},
|
||||||
|
RowActions: rowActions,
|
||||||
|
BulkActions: bulk,
|
||||||
|
}, 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 := true
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileToolbarButtons(toolbar *listToolbar) ([]string, error) {
|
||||||
|
if toolbar == nil || strings.TrimSpace(toolbar.Buttons) == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
switch toolbar.Buttons {
|
||||||
|
case "list_toolbar":
|
||||||
|
return []string{"create"}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported action %s", toolbar.Buttons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}{}
|
||||||
|
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
|
||||||
|
}
|
||||||
108
cabana/schema.go
108
cabana/schema.go
@@ -7,117 +7,9 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
|
||||||
ShowCheckboxes bool `yaml:"showCheckboxes"`
|
|
||||||
ShowSearch bool `yaml:"showSearch"`
|
|
||||||
Toolbar *struct {
|
|
||||||
Buttons string `yaml:"buttons"`
|
|
||||||
Search *struct {
|
|
||||||
Prompt string `yaml:"prompt"`
|
|
||||||
} `yaml:"search"`
|
|
||||||
} `yaml:"toolbar"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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 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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
compiled = append(compiled, ListColumn{
|
|
||||||
Key: key,
|
|
||||||
Label: spec.Label,
|
|
||||||
Searchable: spec.Searchable,
|
|
||||||
Sortable: spec.Sortable,
|
|
||||||
Type: spec.Type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
per := doc.RecordsPerPage
|
|
||||||
if per < 1 {
|
|
||||||
per = 20
|
|
||||||
}
|
|
||||||
showSearch := doc.ShowSearch
|
|
||||||
if doc.Toolbar != nil && doc.Toolbar.Search != nil {
|
|
||||||
showSearch = true
|
|
||||||
}
|
|
||||||
return &ListSchema{
|
|
||||||
Title: doc.Title,
|
|
||||||
RecordsPerPage: per,
|
|
||||||
ShowSearch: showSearch,
|
|
||||||
Columns: compiled,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeStrict(raw []byte, dest any) error {
|
func decodeStrict(raw []byte, dest any) error {
|
||||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||||
if err := dec.Decode(dest); err != nil {
|
if err := dec.Decode(dest); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,97 @@ package cabana
|
|||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ListSort is the compiled default sort. Direction is exactly asc or desc.
|
||||||
|
type ListSort struct {
|
||||||
|
Column string `json:"column"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListColumn is one compiled columns.yaml entry, in file order.
|
||||||
|
// Omitted sortable defaults to true, matching Winter; searchable defaults to false.
|
||||||
|
type ListColumn struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Searchable bool `json:"searchable"`
|
||||||
|
Sortable bool `json:"sortable"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Relation string `json:"relation,omitempty"`
|
||||||
|
Select string `json:"select,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFilter is one compiled config_filter scope. Values stay typed scalars.
|
||||||
|
type ListFilter struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Column string `json:"column,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
ModelClass string `json:"modelClass,omitempty"`
|
||||||
|
NameFrom string `json:"nameFrom,omitempty"`
|
||||||
|
Options []FormOption `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RowAction is a per-record link derived from recordUrl.
|
||||||
|
type RowAction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkAction is a checkbox action. It carries no SQL.
|
||||||
|
type BulkAction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSchema is the boot-compiled list contract for one controller.
|
||||||
|
// Labels stay as source keys until a request localizes a copy.
|
||||||
|
type ListSchema struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
ModelClass string `json:"modelClass,omitempty"`
|
||||||
|
RecordURL string `json:"recordUrl,omitempty"`
|
||||||
|
NoRecordsMessage string `json:"noRecordsMessage,omitempty"`
|
||||||
|
RecordsPerPage int `json:"recordsPerPage"`
|
||||||
|
PerPageOptions []int `json:"perPageOptions"`
|
||||||
|
ShowSearch bool `json:"showSearch"`
|
||||||
|
ShowSetup bool `json:"showSetup"`
|
||||||
|
ShowCheckboxes bool `json:"showCheckboxes"`
|
||||||
|
ShowSorting bool `json:"showSorting"`
|
||||||
|
SearchTerm string `json:"searchTerm"`
|
||||||
|
SearchPrompt string `json:"searchPrompt,omitempty"`
|
||||||
|
DefaultSort *ListSort `json:"defaultSort,omitempty"`
|
||||||
|
ToolbarButtons []string `json:"toolbarButtons"`
|
||||||
|
Columns []ListColumn `json:"columns"`
|
||||||
|
Filters []ListFilter `json:"filters"`
|
||||||
|
RowActions []RowAction `json:"rowActions"`
|
||||||
|
BulkActions []BulkAction `json:"bulkActions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
|
||||||
|
func (s ListSchema) MarshalJSON() ([]byte, error) {
|
||||||
|
type alias ListSchema
|
||||||
|
out := alias(s)
|
||||||
|
if out.PerPageOptions == nil {
|
||||||
|
out.PerPageOptions = []int{}
|
||||||
|
}
|
||||||
|
if out.ToolbarButtons == nil {
|
||||||
|
out.ToolbarButtons = []string{}
|
||||||
|
}
|
||||||
|
if out.Columns == nil {
|
||||||
|
out.Columns = []ListColumn{}
|
||||||
|
}
|
||||||
|
if out.Filters == nil {
|
||||||
|
out.Filters = []ListFilter{}
|
||||||
|
}
|
||||||
|
if out.RowActions == nil {
|
||||||
|
out.RowActions = []RowAction{}
|
||||||
|
}
|
||||||
|
if out.BulkActions == nil {
|
||||||
|
out.BulkActions = []BulkAction{}
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
// FormSchema is the locale-neutral form contract compiled once at boot.
|
// FormSchema is the locale-neutral form contract compiled once at boot.
|
||||||
// Display strings stay as source keys until a request localizes a copy.
|
// Display strings stay as source keys until a request localizes a copy.
|
||||||
type FormSchema struct {
|
type FormSchema struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user