Files
summercms/modules/cabana/list_schema.go
Jakub Zych 55314e41f8 fix(admin): datetime popover clock, unsaved confirm, inferred list dates
BM UAT on v0.1.1 showed a date-only calendar for datetime fields, a stuck discard dialog, and raw ISO timestamps when columns.yaml omitted type. The picker now edits time in the popover, confirm sits above the calendar, and omitted time.Time / Date / TimeOfDay columns compile as datetime / date / time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 05:58:24 +02:00

565 lines
18 KiB
Go

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": {}, "date": {}, "time": {},
}
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"`
HeaderPartial string `yaml:"headerPartial"`
}
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, toolbarActions, err := compileToolbarButtons(ctl, 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
}
if doc.HeaderPartial != "" && !identifier(doc.HeaderPartial) {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("headerPartial %q: %s", doc.HeaderPartial, partialPathHint))
}
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,
ToolbarActions: toolbarActions,
HeaderPartial: doc.HeaderPartial,
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))
}
typ := spec.Type
if typ == "" && checkModel {
if inferred := modelCols[key]; inferred != "" && inferred != "text" {
typ = inferred
}
}
if typ != "" {
if _, known := listColumnTypes[typ]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", typ))
}
}
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: typ,
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, D-12):
// action names in display order. The built-in create and delete sit next to
// names the controller registers through pact.HasAdminActions; decode has no
// controller, so membership is resolved in compileToolbarButtons. A scalar
// (Winter's partial name) is rejected with a pointer at the list syntax.
type toolbarButtons struct {
items []string
}
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")
}
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 or registered actions); the Winter partial %q is not supported", nodeText(node))
}
}
// compileToolbarButtons resolves every toolbar.buttons name against the
// built-in create and delete and the controller's registered actions: the one
// action namespace widgets use too. It returns the names in declared order and
// the registered entries with their (source) labels.
func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showCheckboxes bool) ([]string, []ToolbarAction, error) {
if toolbar == nil || len(toolbar.Buttons.items) == 0 {
return []string{}, []ToolbarAction{}, nil
}
registered := map[string]pact.AdminAction{}
if src, ok := ctl.(pact.HasAdminActions); ok && src != nil {
for _, action := range src.AdminActions() {
registered[action.Name] = action
}
}
out := append([]string(nil), toolbar.Buttons.items...)
custom := []ToolbarAction{}
for _, name := range out {
if builtinToolbarActions[name] {
if name == "delete" && !showCheckboxes {
return nil, nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
}
continue
}
action, ok := registered[name]
if !ok {
return nil, nil, fmt.Errorf("toolbar.buttons: unsupported action %s (want create, delete or an action the controller registers)", name)
}
if strings.TrimSpace(action.Label) == "" {
return nil, nil, fmt.Errorf("toolbar.buttons: action %s needs a label", name)
}
custom = append(custom, ToolbarAction{Name: name, Label: action.Label})
}
return out, custom, 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]string, 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]string{}
rels := map[string]struct{}{}
for _, mf := range modelFields(t) {
field := mf.Field
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] = listColumnKind(field.Type)
}
return cols, rels, true
}
// listColumnKind is the Winter list type of a model field when columns.yaml
// omits type: datetime for time.Time, date for lagoon.Date, time for
// lagoon.TimeOfDay. Everything else stays text (the admin's default).
func listColumnKind(t reflect.Type) string {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t {
case reflect.TypeOf(time.Time{}):
return "datetime"
case dateGoType:
return "date"
case timeOfDayGoType:
return "time"
default:
return "text"
}
}
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
}
// A struct stored in one column (it scans or values itself, such as
// lagoon.Date and lagoon.TimeOfDay) is a scalar, not a relation.
if reflect.PointerTo(t).Implements(scannerType) || t.Implements(valuerType) || reflect.PointerTo(t).Implements(valuerType) {
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...)
out.ToolbarActions = make([]ToolbarAction, len(s.ToolbarActions))
for i, action := range s.ToolbarActions {
action.Label = translateKey(ctx, tr, action.Label)
out.ToolbarActions[i] = action
}
out.Assets = ControllerAssets{
Scripts: append([]string{}, s.Assets.Scripts...),
Styles: append([]string{}, s.Assets.Styles...),
}
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
}