diff --git a/cabana/contracts.go b/cabana/contracts.go index 9fb29f3..785e970 100644 --- a/cabana/contracts.go +++ b/cabana/contracts.go @@ -56,23 +56,6 @@ func (BackendUser) TableName() string { return "backend_users" } // Option is a dropdown choice shared with later schema plans. 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. type CompiledController struct { PluginID string diff --git a/cabana/http.go b/cabana/http.go index 800a883..402454d 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -136,16 +136,11 @@ func constrainController(g pact.Router) { func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { - cols := cc.List.Columns - if cols == nil { - cols = []ListColumn{} + schema := cc.List + if schema == nil { + schema = &ListSchema{} } - WriteData(w, http.StatusOK, &ListSchema{ - Title: cc.List.Title, - RecordsPerPage: cc.List.RecordsPerPage, - ShowSearch: cc.List.ShowSearch, - Columns: cols, - }, map[string]any{}) + WriteData(w, http.StatusOK, schema, map[string]any{}) }) } diff --git a/cabana/list_schema.go b/cabana/list_schema.go new file mode 100644 index 0000000..17f0b75 --- /dev/null +++ b/cabana/list_schema.go @@ -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 +} diff --git a/cabana/schema.go b/cabana/schema.go index dbc6126..a1f52aa 100644 --- a/cabana/schema.go +++ b/cabana/schema.go @@ -7,117 +7,9 @@ import ( "path" "strings" - "git.golem15.com/golem15/summercms/pact" "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 { dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) if err := dec.Decode(dest); err != nil { diff --git a/cabana/schema_types.go b/cabana/schema_types.go index 3c7ee38..99da5f8 100644 --- a/cabana/schema_types.go +++ b/cabana/schema_types.go @@ -2,6 +2,97 @@ package cabana 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. // Display strings stay as source keys until a request localizes a copy. type FormSchema struct {