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

@@ -119,6 +119,20 @@ func AdminLogin() {}
// @Router /auth/refresh [post]
func AdminRefresh() {}
// LangBundle is the public string bundle: full backend::lang key to CLDR
// forms (D-20).
type LangBundle map[string]MessageForms
// AdminLang documents GET /lang.
//
// @Summary Admin UI strings
// @Description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
// @Tags admin
// @Produce json
// @Success 200 {object} Envelope[LangBundle]
// @Router /lang [get]
func AdminLang() {}
// AdminLogout documents POST /auth/logout.
//
// @Summary Admin logout
@@ -233,7 +247,7 @@ func AdminListSchema() {}
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[FormView]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
@@ -250,7 +264,7 @@ func AdminFormSchema() {}
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope
// @Success 200 {object} Envelope[RelationSchema]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope

View File

@@ -38,12 +38,13 @@ var (
)
type formConfigDocument struct {
Name string `yaml:"name"`
Form string `yaml:"form"`
ModelClass string `yaml:"modelClass"`
DefaultRedirect string `yaml:"defaultRedirect"`
Create *formRedirects `yaml:"create"`
Update *formRedirects `yaml:"update"`
Name string `yaml:"name"`
Form string `yaml:"form"`
ModelClass string `yaml:"modelClass"`
DefaultRedirect string `yaml:"defaultRedirect"`
Create *formRedirects `yaml:"create"`
Update *formRedirects `yaml:"update"`
Messages *formMessageKeys `yaml:"messages"`
}
type formRedirects struct {
@@ -102,11 +103,22 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
if err := requireDropdownProvider(ctl, fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
return &FormSchema{
schema := &FormSchema{
Name: doc.Name,
ModelClass: doc.ModelClass,
Fields: fields,
}, nil
redirects: FormRedirects{Default: doc.DefaultRedirect},
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
if doc.Create != nil {
schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose}
}
if doc.Update != nil {
schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose}
}
return schema, nil
}
// Localize copies the cached schema and resolves display strings for one request.
@@ -141,6 +153,8 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,
Meta: FormMeta{Locale: locale},
}, nil
}

View File

@@ -96,6 +96,11 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if app == nil {
return nil, errors.New("cabana: app is nil")
}
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
if err := validateMessageKeys(reg, tr); err != nil {
return nil, err
}
}
guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil {
guards = bouncer.NewRegistry()
@@ -183,6 +188,10 @@ func (s *service) mount(r pact.Router) {
g.Post("/login", s.login, throttle)
g.Post("/refresh", requireAjax(s.refresh))
})
// The string bundle is public: the login screen needs it before auth.
r.GroupRaw(api, nil, func(g pact.Router) {
g.Get("/lang", s.langBundle)
})
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", requireAjax(s.logout))
g.Get("/auth/me", s.me)

24
cabana/lang.go Normal file
View File

@@ -0,0 +1,24 @@
package cabana
import (
"net/http"
)
// adminLangPrefix is the only namespace the public bundle serves (D-20).
const adminLangPrefix = "backend::lang."
// langBundle serves GET {prefix}/api/v1/lang: every backend::lang key as CLDR
// forms for the request locale, with the fallback locale's keys underneath.
// It is public (the login screen needs it) and never includes another
// namespace.
func (s *service) langBundle(w http.ResponseWriter, r *http.Request) {
tr := s.translator()
locale := schemaLocale(r.Context(), tr)
data := map[string]map[string]string{}
if tr != nil {
locale = tr.Resolved(locale, adminLangPrefix)
data = tr.Bundle(locale, adminLangPrefix)
}
w.Header().Set("Cache-Control", "no-cache")
WriteData(w, http.StatusOK, data, map[string]any{"locale": locale})
}

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
}

View File

@@ -32,12 +32,16 @@ defaultSort:
column: name
direction: asc
toolbar:
buttons: list_toolbar
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
`
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}]}`
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}`
// defaultListMessagesJSON is a compiled list's messages block when the YAML
// declares none: every key is a framework default phrase key (D-13).
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}`
func TestListSchemaCompile(t *testing.T) {
columns := readListFixture(t, "testdata/list/all_columns.yaml")
@@ -52,7 +56,7 @@ func TestListSchemaCompile(t *testing.T) {
for _, want := range []string{
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create"]`, `"filters":[]`,
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"]`, `"filters":[]`,
} {
if !strings.Contains(got, want) {
t.Fatalf("compiled list missing %s:\n%s", want, got)
@@ -68,7 +72,7 @@ noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: list_toolbar
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
`
@@ -98,7 +102,7 @@ modelClass: Widget
recordsPerPage: 20
`
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[]}`
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
if got != want {
t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
}
@@ -115,7 +119,7 @@ modelClass: Widget
recordsPerPage: 20
`
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[]}`
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
if got != want {
t.Fatalf("single list =\n%s\nwant\n%s", got, want)
}
@@ -217,7 +221,7 @@ func TestListSchemaRejects(t *testing.T) {
},
{
name: "unsupported action",
config: strings.Replace(allColumnsConfig, "buttons: list_toolbar", "buttons: drop_database", 1),
config: strings.Replace(allColumnsConfig, "buttons: [create, delete]", "buttons: [create, drop_database]", 1),
columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"},
},
@@ -307,7 +311,7 @@ const filterColumns = `columns:
searchable: true
`
const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[]}`
const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
func TestListSchemaFilter(t *testing.T) {
filters := readListFixture(t, "testdata/list/all_filters.yaml")

257
cabana/messages.go Normal file
View File

@@ -0,0 +1,257 @@
package cabana
import (
"context"
"encoding/json"
"fmt"
"reflect"
"sort"
"git.golem15.com/golem15/summercms/phrasebook"
)
// MessageForms is one controller message resolved to its CLDR plural forms
// (D-13). "other" is always present; the SPA picks a form with
// Intl.PluralRules and interpolates :count, :name and :term (D-24).
type MessageForms map[string]string
// MarshalJSON keeps an unset message an object, never null.
func (m MessageForms) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("{}"), nil
}
return json.Marshal(map[string]string(m))
}
// listMessageKeys is the config_list.yaml messages block: phrase keys.
type listMessageKeys struct {
RecordCount string `yaml:"recordCount"`
Create string `yaml:"create"`
SearchPrompt string `yaml:"searchPrompt"`
Empty string `yaml:"empty"`
EmptySearch string `yaml:"emptySearch"`
EmptySearchHint string `yaml:"emptySearchHint"`
Selected string `yaml:"selected"`
DeleteSelected string `yaml:"deleteSelected"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
}
// ListMessages is a list schema's copy, every key resolved (same field order
// as listMessageKeys).
type ListMessages struct {
RecordCount MessageForms `json:"recordCount"`
Create MessageForms `json:"create"`
SearchPrompt MessageForms `json:"searchPrompt"`
Empty MessageForms `json:"empty"`
EmptySearch MessageForms `json:"emptySearch"`
EmptySearchHint MessageForms `json:"emptySearchHint"`
Selected MessageForms `json:"selected"`
DeleteSelected MessageForms `json:"deleteSelected"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
}
// formMessageKeys is the config_form.yaml messages block: phrase keys.
type formMessageKeys struct {
Create string `yaml:"create"`
Update string `yaml:"update"`
Saved string `yaml:"saved"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
}
// FormMessages is a form's copy, every key resolved.
type FormMessages struct {
Create MessageForms `json:"create"`
Update MessageForms `json:"update"`
Saved MessageForms `json:"saved"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
}
// relationMessageKeys is one config_relation.yaml relation's messages block.
type relationMessageKeys struct {
Link string `yaml:"link"`
LinkHint string `yaml:"linkHint"`
CandidateSearch string `yaml:"candidateSearch"`
Linked string `yaml:"linked"`
UnlinkSelected string `yaml:"unlinkSelected"`
UnlinkConfirm string `yaml:"unlinkConfirm"`
Unlinked string `yaml:"unlinked"`
Empty string `yaml:"empty"`
}
// RelationMessages is a relation manager's copy, every key resolved.
type RelationMessages struct {
Link MessageForms `json:"link"`
LinkHint MessageForms `json:"linkHint"`
CandidateSearch MessageForms `json:"candidateSearch"`
Linked MessageForms `json:"linked"`
UnlinkSelected MessageForms `json:"unlinkSelected"`
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
Unlinked MessageForms `json:"unlinked"`
Empty MessageForms `json:"empty"`
}
// Framework defaults (D-13): every omitted key falls back to backend::lang.
var (
listMessageDefaults = listMessageKeys{
RecordCount: "backend::lang.messages.list.record_count",
Create: "backend::lang.messages.list.create",
SearchPrompt: "backend::lang.list.search_prompt",
Empty: "backend::lang.list.no_records",
EmptySearch: "backend::lang.messages.list.empty_search",
EmptySearchHint: "backend::lang.messages.list.empty_search_hint",
Selected: "backend::lang.messages.list.selected",
DeleteSelected: "backend::lang.list.delete_selected",
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
Deleted: "backend::lang.messages.list.deleted",
}
formMessageDefaults = formMessageKeys{
Create: "backend::lang.messages.form.create",
Update: "backend::lang.messages.form.update",
Saved: "backend::lang.messages.form.saved",
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
Deleted: "backend::lang.messages.form.deleted",
}
relationMessageDefaults = relationMessageKeys{
Link: "backend::lang.messages.relation.link",
LinkHint: "backend::lang.messages.relation.link_hint",
CandidateSearch: "backend::lang.messages.relation.candidate_search",
Linked: "backend::lang.messages.relation.linked",
UnlinkSelected: "backend::lang.messages.relation.unlink_selected",
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
Unlinked: "backend::lang.messages.relation.unlinked",
Empty: "backend::lang.messages.relation.empty",
}
)
// withDefaults fills every empty key of keys from defaults.
func withDefaults[K any](keys, defaults K) K {
out := keys
dst := reflect.ValueOf(&out).Elem()
src := reflect.ValueOf(defaults)
for i := 0; i < dst.NumField(); i++ {
if dst.Field(i).String() == "" {
dst.Field(i).SetString(src.Field(i).String())
}
}
return out
}
// messageKeyPairs lists (yaml name, phrase key) for every key, in field order.
func messageKeyPairs[K any](keys K) [][2]string {
v := reflect.ValueOf(keys)
t := v.Type()
out := make([][2]string, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
out = append(out, [2]string{t.Field(i).Tag.Get("yaml"), v.Field(i).String()})
}
return out
}
// localizeMessages resolves each key of K into the same-index MessageForms
// field of M. Without a translator, or for a key that cannot convert, the
// form is {"other": text} where text is the translation or the key itself.
func localizeMessages[K any, M any](ctx context.Context, tr *phrasebook.Translator, keys K) M {
var out M
dst := reflect.ValueOf(&out).Elem()
src := reflect.ValueOf(keys)
locale := schemaLocale(ctx, tr)
for i := 0; i < src.NumField(); i++ {
key := src.Field(i).String()
dst.Field(i).Set(reflect.ValueOf(messageForms(tr, locale, key)))
}
return out
}
func messageForms(tr *phrasebook.Translator, locale, key string) MessageForms {
if key == "" {
return MessageForms{"other": ""}
}
if tr == nil {
return MessageForms{"other": key}
}
if forms, ok := tr.Forms(locale, key); ok {
return MessageForms(forms)
}
return MessageForms{"other": tr.GetIn(locale, key, nil)}
}
// listMessageKeySet is the list's resolved key set: declared keys over the
// framework defaults, with searchPrompt and empty defaulting to the list's own
// toolbar.search.prompt and noRecordsMessage when those are set.
func (s *ListSchema) listMessageKeySet() listMessageKeys {
defaults := listMessageDefaults
if s.SearchPrompt != "" {
defaults.SearchPrompt = s.SearchPrompt
}
if s.NoRecordsMessage != "" {
defaults.Empty = s.NoRecordsMessage
}
return withDefaults(s.messageKeys, defaults)
}
// formMessageKeySet is the form's resolved key set; create defaults to the
// config_form.yaml name when it is set.
func (s *FormSchema) formMessageKeySet() formMessageKeys {
defaults := formMessageDefaults
if s.Name != "" {
defaults.Create = s.Name
}
return withDefaults(s.messageKeys, defaults)
}
func (s *RelationSchema) relationMessageKeySet() relationMessageKeys {
return withDefaults(s.messageKeys, relationMessageDefaults)
}
// validateMessageKeys fails activation when a controller's messages block (or
// a framework default) names a phrase key no locale defines (D-13). Defaults
// taken from the list's own search prompt, empty message or the form name are
// Winter strings that may be literal text, so they are not checked.
func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
if reg == nil || tr == nil {
return nil
}
ids := make([]string, 0, len(reg.byID))
for id := range reg.byID {
ids = append(ids, id)
}
sort.Strings(ids)
check := func(pluginID, id, file string, pairs [][2]string) error {
for _, pair := range pairs {
if pair[1] != "" && !tr.Has(pair[1]) {
return bootErr(pluginID, id, file, fmt.Errorf("messages.%s names missing phrase key %s", pair[0], pair[1]))
}
}
return nil
}
for _, id := range ids {
cc := reg.byID[id]
if cc.List != nil {
if err := check(cc.PluginID, id, "config_list.yaml", messageKeyPairs(withDefaults(cc.List.messageKeys, listMessageDefaults))); err != nil {
return err
}
}
if cc.Form != nil {
if err := check(cc.PluginID, id, "config_form.yaml", messageKeyPairs(withDefaults(cc.Form.messageKeys, formMessageDefaults))); err != nil {
return err
}
}
names := make([]string, 0, len(cc.Relations))
for name := range cc.Relations {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if schema := cc.Relations[name].Schema; schema != nil {
if err := check(cc.PluginID, id, "config_relation.yaml", messageKeyPairs(schema.relationMessageKeySet())); err != nil {
return fmt.Errorf("relation %s: %w", name, err)
}
}
}
}
return nil
}

354
cabana/messages_test.go Normal file
View File

@@ -0,0 +1,354 @@
package cabana
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/phrasebook"
"git.golem15.com/golem15/summercms/towel"
)
type langPlugin struct {
id string
lang fs.FS
}
func (p langPlugin) ID() string { return p.id }
func (p langPlugin) LangFS() fs.FS { return p.lang }
// messagesTranslator activates phrasebook with the framework backend strings
// and an acme.demo catalog.
func messagesTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
app := backpack.New(nil)
demo := fstest.MapFS{
"lang/pl/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widżet"
few: ":count widżety"
many: ":count widżetów"
other: ":count widżetu"
create: Nowy widżet
saved: Widżet „:name” zapisany
link: Dodaj edytora
`)},
"lang/en/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widget"
other: ":count widgets"
create: New widget
saved: Widget “:name” saved
link: Add editor
`)},
}
if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok || tr == nil {
t.Fatal("translator missing")
}
return tr
}
const messagesListConfig = `modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
recordCount: acme.demo::lang.widgets.count
create: acme.demo::lang.widgets.create
`
func localeCtx(locale string) context.Context {
return towel.WithLocale(context.Background(), locale)
}
func TestPhase10Messages(t *testing.T) {
tr := messagesTranslator(t)
schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n"))
if err != nil {
t.Fatal(err)
}
pl, err := schema.Localize(localeCtx("pl"), tr)
if err != nil {
t.Fatal(err)
}
m := pl.Messages
if m == nil {
t.Fatal("localized list has no messages")
}
if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" {
t.Fatalf("plural recordCount=%v", m.RecordCount)
}
if m.Create["other"] != "Nowy widżet" {
t.Fatalf("plugin create=%v", m.Create)
}
for name, got := range map[string]MessageForms{
"deleteConfirm": m.DeleteConfirm,
"searchPrompt": m.SearchPrompt,
"empty": m.Empty,
"deleteSelected": m.DeleteSelected,
"selected": m.Selected,
} {
if got["other"] == "" || strings.Contains(got["other"], "::") {
t.Fatalf("default %s did not resolve: %v", name, got)
}
}
if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" {
t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt)
}
if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" {
t.Fatalf("default plural deleted=%v", m.Deleted)
}
en, err := schema.Localize(localeCtx("en"), tr)
if err != nil {
t.Fatal(err)
}
if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" {
t.Fatalf("en messages=%+v", en.Messages)
}
raw, err := json.Marshal(pl)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"messages":{"recordCount":{`) {
t.Fatalf("served list JSON has no messages: %s", raw)
}
if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" {
t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount)
}
t.Run("form", func(t *testing.T) {
config := `name: acme.demo::lang.widgets.create
form: ~/plugins/acme/demo/models/widget/fields.yaml
modelClass: Widget
defaultRedirect: acme/demo/widgets
create:
redirect: acme/demo/widgets/update/:id
redirectClose: acme/demo/widgets
update:
redirect: acme/demo/widgets
redirectClose: acme/demo/widgets
messages:
saved: acme.demo::lang.widgets.saved
`
form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n"))
if err != nil {
t.Fatal(err)
}
view, err := form.Localize(localeCtx("pl"), tr, nil)
if err != nil {
t.Fatal(err)
}
if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" {
t.Fatalf("form messages=%+v", view.Messages)
}
want := FormRedirects{Default: "acme/demo/widgets",
Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"},
Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}}
if view.Redirects != want {
t.Fatalf("redirects=%+v", view.Redirects)
}
raw, err := view.CanonicalJSON()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) {
t.Fatalf("form view JSON=%s", raw)
}
})
t.Run("relation", func(t *testing.T) {
yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
view := relations["editors"].Schema.Localize(localeCtx("pl"), tr)
if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" {
t.Fatalf("relation messages=%+v", view.Messages)
}
})
t.Run("unknown key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1)
if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") {
t.Fatalf("list err=%v", err)
}
form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n"
if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") {
t.Fatalf("form err=%v", err)
}
yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
compiled, _ := CompileForm("acme.demo", ctl, fsys)
if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") {
t.Fatalf("relation err=%v", err)
}
})
t.Run("missing phrase key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1)
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
err = validateMessageKeys(reg, tr)
if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("err=%v", err)
}
ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
if err := validateMessageKeys(ok, tr); err != nil {
t.Fatalf("declared keys failed: %v", err)
}
if err := validateMessageKeys(reg, nil); err != nil {
t.Fatalf("no translator must skip the check: %v", err)
}
})
}
func TestPhase10Toolbar(t *testing.T) {
base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n"
compile := func(buttons string, checkboxes bool) (*ListSchema, error) {
config := strings.Replace(base, "BUTTONS", buttons, 1)
if !checkboxes {
config = strings.Replace(config, "showCheckboxes: true\n", "", 1)
}
return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n"))
}
for _, tc := range []struct {
buttons string
want []string
}{
{"[create, delete]", []string{"create", "delete"}},
{"[delete, create]", []string{"delete", "create"}},
{"[create]", []string{"create"}},
{"[]", []string{}},
{"\n - delete\n - create", []string{"delete", "create"}},
} {
schema, err := compile(tc.buttons, true)
if err != nil {
t.Fatalf("%s: %v", tc.buttons, err)
}
if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want)
}
}
for _, tc := range []struct {
buttons string
checkboxes bool
want string
}{
{"list_toolbar", true, "toolbar.buttons must be a list"},
{"list_toolbar", true, `"list_toolbar" is not supported`},
{"[create, delete]", false, "showCheckboxes"},
{"[create, create]", true, "duplicate action create"},
{"[create, export]", true, "unsupported action export"},
{"[{create: true}]", true, "toolbar.buttons"},
} {
_, err := compile(tc.buttons, tc.checkboxes)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want)
}
}
// A controller without a compiled form cannot create: create is dropped.
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get("acme.demo.widgets")
if strings.Join(cc.List.ToolbarButtons, ",") != "delete" {
t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons)
}
}
func TestPhase10Bundle(t *testing.T) {
tr := messagesTranslator(t)
app := backpack.New(nil)
if err := app.Publish(tr); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
if locale != "" {
req = req.WithContext(towel.WithLocale(req.Context(), locale))
}
rec := httptest.NewRecorder()
svc.langBundle(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatal(err)
}
return body.Data, body.Meta.Locale, rec
}
pl, locale, rec := call("pl")
if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control"))
}
if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 {
t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"])
}
if len(pl) < 50 {
t.Fatalf("pl bundle has only %d keys", len(pl))
}
for key := range pl {
if !strings.HasPrefix(key, "backend::lang.") {
t.Fatalf("bundle leaked a key outside backend::lang: %s", key)
}
}
if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") {
t.Fatalf("bundle leaked another namespace: %s", rec.Body.String())
}
en, locale, _ := call("en-GB")
if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"])
}
fallback, locale, _ := call("de")
if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"])
}
// No translator: an empty bundle, still an envelope.
empty := &service{app: backpack.New(nil)}
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
out := httptest.NewRecorder()
empty.langBundle(out, req)
if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) {
t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String())
}
}

View File

@@ -71,8 +71,9 @@ func TestPhase09ContractInventory(t *testing.T) {
if !ok200 && !ok201 {
t.Fatalf("%s has no 200 or 201 response", key)
}
if public[key] {
if _, ok := op.Responses["401"]; !ok {
if route.public {
// Login and refresh answer 401; the public string bundle does not.
if _, ok := op.Responses["401"]; public[key] && !ok {
t.Fatalf("%s has no 401 response", key)
}
continue

View File

@@ -77,6 +77,10 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if err != nil {
return nil, err
}
if form == nil && list != nil {
// Without a compiled form there is nothing to create (D-14).
list.ToolbarButtons = withoutAction(list.ToolbarButtons, "create")
}
compiled := &CompiledController{
PluginID: item.plugin.ID(),
Controller: item.ctl,
@@ -93,6 +97,16 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
return &Registry{byID: byID}, nil
}
func withoutAction(actions []string, drop string) []string {
out := make([]string, 0, len(actions))
for _, action := range actions {
if action != drop {
out = append(out, action)
}
}
return out
}
func compileContributions(reg *Registry, plugins []party.Plugin) error {
if reg == nil {
return fmt.Errorf("cabana: registry is nil")

View File

@@ -61,6 +61,11 @@ type RelationSchema struct {
Label string `json:"label"`
View RelationPanel `json:"view"`
Manage RelationPanel `json:"manage"`
// Messages is the relation manager's copy (D-13); the cached schema
// carries each phrase key as its own form.
Messages *RelationMessages `json:"messages"`
messageKeys relationMessageKeys
}
// CompiledRelation combines trusted YAML with model-owned metadata.
@@ -100,6 +105,10 @@ type RelationMutationResult struct {
type RelationService struct{ DB *gorm.DB }
func (s RelationSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, s.relationMessageKeySet())
s.Messages = &keys
}
type alias RelationSchema
out := alias(s)
ensureRelationArrays(&out.View)
@@ -125,6 +134,8 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator
out.Label = translateKey(ctx, tr, s.Label)
out.View = localizeRelationPanel(ctx, tr, s.View)
out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
out.Messages = &messages
return &out
}
@@ -144,9 +155,10 @@ type relationRoot struct {
}
type relationDocument struct {
Label string `yaml:"label"`
View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"`
Label string `yaml:"label"`
View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"`
Messages *relationMessageKeys `yaml:"messages"`
}
type relationPanelDocument struct {
@@ -236,8 +248,14 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
}
orderRelationColumns(raw, &view)
orderRelationColumns(raw, &manage)
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
schema.Messages = &keys
out[name] = &CompiledRelation{
Schema: &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage},
Schema: schema,
Contract: contract,
RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
}

View File

@@ -1,6 +1,9 @@
package cabana
import "encoding/json"
import (
"context"
"encoding/json"
)
// ListSort is the compiled default sort. Direction is exactly asc or desc.
type ListSort struct {
@@ -68,11 +71,20 @@ type ListSchema struct {
Filters []ListFilter `json:"filters"`
RowActions []RowAction `json:"rowActions"`
BulkActions []BulkAction `json:"bulkActions"`
Meta *FormMeta `json:"meta,omitempty"`
// Messages is the list's copy (D-13). The cached schema carries each
// phrase key as its own form; a response resolves them in its locale.
Messages *ListMessages `json:"messages"`
Meta *FormMeta `json:"meta,omitempty"`
messageKeys listMessageKeys
}
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
func (s ListSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, s.listMessageKeySet())
s.Messages = &keys
}
type alias ListSchema
out := alias(s)
if out.PerPageOptions == nil {
@@ -102,6 +114,9 @@ type FormSchema struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
messageKeys formMessageKeys
redirects FormRedirects
}
// FormView is one request's localized form, including the locale actually used.
@@ -109,7 +124,25 @@ type FormView struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
Meta FormMeta `json:"meta"`
// Messages is the form's copy resolved in the request locale (D-13).
Messages FormMessages `json:"messages"`
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
// them onto its routes.
Redirects FormRedirects `json:"redirects"`
Meta FormMeta `json:"meta"`
}
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.
type FormRedirects struct {
Default string `json:"default"`
Create FormRedirect `json:"create"`
Update FormRedirect `json:"update"`
}
// FormRedirect is one Winter redirect pair.
type FormRedirect struct {
Redirect string `json:"redirect"`
RedirectClose string `json:"redirectClose"`
}
// FormMeta reports the locale selected for a schema response.

View File

@@ -34,6 +34,7 @@ const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name
var phase09Routes = []adminRoute{
{key: "POST /auth/login", public: true},
{key: "POST /auth/refresh", public: true},
{key: "GET /lang", public: true},
{key: "POST /auth/logout"},
{key: "GET /auth/me"},
{key: "GET /navigation"},