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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
24
cabana/lang.go
Normal 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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
257
cabana/messages.go
Normal 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
354
cabana/messages_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)...),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user