feat(17-01): repeater field type and the dependent dropdown save recheck

- cabana: type repeater with nested form.fields (text, textarea, number,
  checkbox, switch, dropdown, richeditor), prompt, minItems, maxItems,
  titleFrom; groups, style, nested repeaters and other types fail boot
- rows decoded before projection: only nested keys, coerced scalars,
  per-row required (field.index.nested), row bounds; ProjectWritableFields
  exempts repeater lists only, nestedValue unchanged
- nested richeditor values sanitized with notices under the row path
- dependent dropdowns rechecked on save, top level and per row; null when
  the sibling takes no choice, 422 form.dependent_invalid otherwise
- dependent-options accepts repeater.field; schema carries RepeaterForm
- backend lang repeater.max_items, repeater.min_items,
  form.dependent_invalid; TestCompanionsRepeaterSmoke; docs
This commit is contained in:
2026-10-12 15:24:01 +02:00
parent 71fa597830
commit 46835fd064
16 changed files with 961 additions and 43 deletions

View File

@@ -27,7 +27,7 @@ var (
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {}, "permissioneditor": {},
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
"richeditor": {},
"richeditor": {}, "repeater": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -46,6 +46,8 @@ var (
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
"preset": {}, "dependsOn": {},
"form": {}, "maxItems": {}, "minItems": {}, "titleFrom": {},
"groups": {}, "style": {},
}
// widgetKeys are valid only on `type: widget` (D-06).
widgetKeys = []string{"widget", "action", "fill"}
@@ -264,26 +266,10 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
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)
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
field.Prompt = translateKey(ctx, tr, src.Prompt)
field.Fill = append([]string(nil), src.Fill...)
if src.Preset != nil {
preset := *src.Preset
field.Preset = &preset
}
field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...)
options, err := localizeOptions(ctx, tr, src, provider)
field, err := localizeField(ctx, tr, src, provider, src.Name)
if err != nil {
return nil, err
}
field.Options = options
fields[i] = field
}
if fields == nil {
@@ -329,6 +315,45 @@ func (s *FormSchema) CanonicalJSON() ([]byte, error) {
return json.Marshal(out)
}
// localizeField copies one field and resolves its display strings and
// options for a request. path is the name the dropdown provider is asked
// with: the field name, or repeater.nested for a field of a repeater row.
func localizeField(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider, path string) (FormField, error) {
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)
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
field.Prompt = translateKey(ctx, tr, src.Prompt)
field.Fill = append([]string(nil), src.Fill...)
if src.Preset != nil {
preset := *src.Preset
field.Preset = &preset
}
field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...)
named := src
named.Name = path
options, err := localizeOptions(ctx, tr, named, provider)
if err != nil {
return FormField{}, err
}
field.Options = options
if src.Form != nil {
nested := make([]FormField, len(src.Form.Fields))
for i, inner := range src.Form.Fields {
nested[i], err = localizeField(ctx, tr, inner, provider, src.Name+"."+inner.Name)
if err != nil {
return FormField{}, err
}
}
field.Form = &RepeaterForm{Fields: nested}
}
return field, nil
}
func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error {
var method string
for _, field := range fields {
@@ -336,6 +361,16 @@ func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error
method = field.optionsMethod
break
}
if field.Form != nil {
for _, inner := range field.Form.Fields {
if inner.optionsMethod != "" && method == "" {
method = inner.optionsMethod
}
}
if method != "" {
break
}
}
}
if method == "" || dropdownProvider(ctl) != nil {
return nil
@@ -602,6 +637,9 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
if err := compileDependsOn(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compileRepeater(name, typ, values, &field); err != nil {
return FormField{}, err
}
if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node)
if err != nil {