feat(10-02): backend strings, controller messages and declarative toolbar

- phrasebook ships the backend::lang admin strings (pl, en) with CLDR
  plural maps, loads them as namespace backend, applies
  pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml)
  after every namespace, and fails activation when a backend key cannot
  convert to plural forms
- Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps
- Public GET /lang returns every backend::lang key for the request
  locale over the fallback locale, Cache-Control no-cache
- config_list, config_form and config_relation accept a strict messages
  block; omitted keys take framework defaults, schemas serve every message
  as CLDR forms, and activation fails on a missing phrase key
- toolbar.buttons is an ordered [create, delete] list; the Winter string
  form, duplicates, unknown actions and delete without showCheckboxes fail
  at boot, and create is dropped when the controller has no form
- Form schema serves the raw Winter redirects; scaffold emits the list
  syntax; form and relation schema routes are typed in the admin OpenAPI
This commit is contained in:
Jakub Zych
2026-09-27 16:16:32 +02:00
parent fe04dbc89e
commit c87148a34f
24 changed files with 2148 additions and 45 deletions

View File

@@ -13,6 +13,7 @@ import (
"git.golem15.com/golem15/summercms/phrasebook"
"git.golem15.com/golem15/summercms/towel"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"gorm.io/gorm"
)
@@ -37,6 +38,7 @@ type listDocument struct {
DefaultSort *listSortDocument `yaml:"defaultSort"`
Toolbar *listToolbar `yaml:"toolbar"`
Filter string `yaml:"filter"`
Messages *listMessageKeys `yaml:"messages"`
}
type listSortDocument struct {
@@ -45,7 +47,7 @@ type listSortDocument struct {
}
type listToolbar struct {
Buttons string `yaml:"buttons"`
Buttons toolbarButtons `yaml:"buttons"`
Search *struct {
Prompt string `yaml:"prompt"`
Mode string `yaml:"mode"`
@@ -123,7 +125,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
}
}
buttons, err := compileToolbarButtons(doc.Toolbar)
buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
@@ -154,7 +156,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
return nil, err
}
}
return &ListSchema{
schema := &ListSchema{
Title: doc.Title,
ModelClass: doc.ModelClass,
RecordURL: doc.RecordURL,
@@ -173,7 +175,13 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
Filters: filters,
RowActions: rowActions,
BulkActions: bulk,
}, nil
}
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) {
@@ -270,16 +278,58 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
return options, nil
}
func compileToolbarButtons(toolbar *listToolbar) ([]string, error) {
if toolbar == nil || strings.TrimSpace(toolbar.Buttons) == "" {
// 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
}
switch toolbar.Buttons {
case "list_toolbar":
return []string{"create"}, nil
default:
return nil, fmt.Errorf("unsupported action %s", toolbar.Buttons)
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) {
@@ -391,6 +441,8 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*
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
}