feat(09-03): localize form schemas and resolve dropdown options

- Cached schemas stay source-key IR and each response carries its own meta.locale
- YAML option maps keep declaration order and scalar type; method options call DropdownOptions and fail boot without a provider
This commit is contained in:
Jakub Zych
2026-09-24 18:21:59 +02:00
parent ad1b76085a
commit da8828ef38
2 changed files with 152 additions and 18 deletions

View File

@@ -14,6 +14,7 @@ import (
"git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/phrasebook"
"git.golem15.com/golem15/summercms/towel"
"github.com/goccy/go-yaml" "github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/ast"
) )
@@ -98,6 +99,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
if fields == nil { if fields == nil {
fields = []FormField{} fields = []FormField{}
} }
if err := requireDropdownProvider(ctl, fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
return &FormSchema{ return &FormSchema{
Name: doc.Name, Name: doc.Name,
ModelClass: doc.ModelClass, ModelClass: doc.ModelClass,
@@ -105,6 +109,54 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
}, nil }, nil
} }
// Localize copies the cached schema and resolves display strings for one request.
// It does not mutate s. Option values keep the scalar captured at boot.
func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) {
if s == nil {
return nil, fmt.Errorf("cabana: nil form schema")
}
if ctx == nil {
ctx = context.Background()
}
locale := schemaLocale(ctx, tr)
ctx = towel.WithLocale(ctx, locale)
fields := make([]FormField, len(s.Fields))
for i, src := range s.Fields {
field := src
field.Label = translateKey(ctx, tr, src.Label)
field.Comment = translateKey(ctx, tr, src.Comment)
field.Tab = translateKey(ctx, tr, src.Tab)
field.EmptyOption = translateKey(ctx, tr, src.EmptyOption)
options, err := localizeOptions(ctx, tr, src, provider)
if err != nil {
return nil, err
}
field.Options = options
fields[i] = field
}
if fields == nil {
fields = []FormField{}
}
return &FormView{
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Meta: FormMeta{Locale: locale},
}, nil
}
// CanonicalJSON is one localized schema response.
func (v *FormView) CanonicalJSON() ([]byte, error) {
if v == nil {
return nil, fmt.Errorf("cabana: nil form view")
}
out := *v
if out.Fields == nil {
out.Fields = []FormField{}
}
return json.Marshal(out)
}
// CanonicalJSON is the stable, locale-neutral form contract. // CanonicalJSON is the stable, locale-neutral form contract.
func (s *FormSchema) CanonicalJSON() ([]byte, error) { func (s *FormSchema) CanonicalJSON() ([]byte, error) {
if s == nil { if s == nil {
@@ -117,6 +169,98 @@ func (s *FormSchema) CanonicalJSON() ([]byte, error) {
return json.Marshal(out) return json.Marshal(out)
} }
func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error {
var method string
for _, field := range fields {
if field.optionsMethod != "" {
method = field.optionsMethod
break
}
}
if method == "" || dropdownProvider(ctl) != nil {
return nil
}
return fmt.Errorf("dropdown method %s requires DropdownOptions", method)
}
func dropdownProvider(ctl pact.AdminController) pact.DropdownOptionsProvider {
if ctl == nil {
return nil
}
if p, ok := ctl.(pact.DropdownOptionsProvider); ok && p != nil {
return p
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
rec := src.NewRecord()
p, ok := rec.(pact.DropdownOptionsProvider)
if !ok || p == nil {
return nil
}
return p
}
func schemaLocale(ctx context.Context, tr *phrasebook.Translator) string {
if loc, ok := towel.Locale(ctx); ok {
if primary := primaryLanguage(loc); primary != "" {
return primary
}
}
if tr != nil {
if loc := strings.TrimSpace(tr.Locale()); loc != "" {
return loc
}
}
return "en"
}
func primaryLanguage(header string) string {
header = strings.TrimSpace(header)
if header == "" {
return ""
}
if i := strings.IndexAny(header, ",;"); i >= 0 {
header = strings.TrimSpace(header[:i])
}
return header
}
func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string {
if key == "" || tr == nil {
return key
}
return tr.Get(ctx, key, nil)
}
func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider) ([]FormOption, error) {
if src.optionsMethod != "" {
if provider == nil {
return nil, fmt.Errorf("cabana: dropdown method %s requires DropdownOptions", src.optionsMethod)
}
provided := provider.DropdownOptions(src.Name)
opts := make([]FormOption, len(provided))
for i, opt := range provided {
raw, err := json.Marshal(opt.Value)
if err != nil {
return nil, err
}
opts[i] = FormOption{Value: jsonScalar{raw: raw}, Label: translateKey(ctx, tr, opt.Label)}
}
return opts, nil
}
if len(src.Options) == 0 {
return nil, nil
}
opts := make([]FormOption, len(src.Options))
for i, opt := range src.Options {
opt.Label = translateKey(ctx, tr, opt.Label)
opts[i] = opt
}
return opts, nil
}
func decodeFields(raw []byte) ([]FormField, error) { func decodeFields(raw []byte) ([]FormField, error) {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
var doc formFieldsFile var doc formFieldsFile
@@ -458,24 +602,6 @@ func normalizeYAMLError(err error) error {
return err return err
} }
// Localize resolves display strings for one request. It does not yet.
func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) {
if s == nil || ctx == nil {
return nil, fmt.Errorf("cabana: form localization is not implemented")
}
_ = tr
_ = provider
return nil, fmt.Errorf("cabana: form localization is not implemented")
}
// CanonicalJSON will emit one localized response. It does not yet.
func (v *FormView) CanonicalJSON() ([]byte, error) {
if v == nil {
return nil, fmt.Errorf("cabana: form localization is not implemented")
}
return nil, fmt.Errorf("cabana: form localization is not implemented")
}
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") { if dir == "." || strings.HasPrefix(dir, "..") {

View File

@@ -116,6 +116,14 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
return nil return nil
} }
// Locale returns the configured app locale. Request locales stay on the context.
func (t *Translator) Locale() string {
if t == nil || strings.TrimSpace(t.locale) == "" {
return defaultLocale
}
return t.locale
}
// Get returns the translation for key using the request locale when present, // Get returns the translation for key using the request locale when present,
// otherwise the configured app locale. // otherwise the configured app locale.
func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string { func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {