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:
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/towel"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
@@ -98,6 +99,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
||||
if fields == nil {
|
||||
fields = []FormField{}
|
||||
}
|
||||
if err := requireDropdownProvider(ctl, fields); err != nil {
|
||||
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||
}
|
||||
return &FormSchema{
|
||||
Name: doc.Name,
|
||||
ModelClass: doc.ModelClass,
|
||||
@@ -105,6 +109,54 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
||||
}, 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.
|
||||
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
|
||||
if s == nil {
|
||||
@@ -117,6 +169,98 @@ func (s *FormSchema) CanonicalJSON() ([]byte, error) {
|
||||
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) {
|
||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||
var doc formFieldsFile
|
||||
@@ -458,24 +602,6 @@ func normalizeYAMLError(err error) error {
|
||||
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) {
|
||||
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
||||
if dir == "." || strings.HasPrefix(dir, "..") {
|
||||
|
||||
@@ -116,6 +116,14 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
|
||||
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,
|
||||
// otherwise the configured app locale.
|
||||
func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {
|
||||
|
||||
Reference in New Issue
Block a user