Files
summercms/modules/cabana/field_repeater.go
Jakub Zych 46835fd064 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
2026-10-12 15:24:01 +02:00

488 lines
14 KiB
Go

package cabana
import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/seaglass"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
)
// repeaterType is the core list-of-rows field type (D-07).
const repeaterType = "repeater"
// The phrase keys of the repeater's row count messages (backend::lang).
const (
msgRepeaterMaxItems = "backend::lang.repeater.max_items"
msgRepeaterMinItems = "backend::lang.repeater.min_items"
)
// repeaterKeys are the fields.yaml keys a repeater field accepts.
var repeaterKeys = map[string]bool{
"label": true, "comment": true, "span": true, "type": true, "required": true,
"tab": true, "context": true, "prompt": true,
"maxItems": true, "minItems": true, "titleFrom": true, "form": true,
}
// repeaterOnlyKeys are valid on type repeater only.
var repeaterOnlyKeys = []string{"form", "maxItems", "minItems", "titleFrom"}
// repeaterNestedTypes are the field types a repeater row may hold.
var repeaterNestedTypes = map[string]bool{
"text": true, "textarea": true, "number": true, "checkbox": true,
"switch": true, "dropdown": true, richEditorType: true,
}
// compileRepeater decodes a `type: repeater` field: its nested form.fields
// (compiled with the rules of top-level fields, restricted to
// repeaterNestedTypes), prompt, maxItems, minItems and titleFrom. WinterCMS's
// groups and style are refused loudly on any field, the repeater-only keys on
// any other type. The span is always full.
func compileRepeater(name, typ string, values map[string]ast.Node, field *FormField) error {
if _, ok := values["groups"]; ok {
return fmt.Errorf("repeater groups are not supported")
}
if _, ok := values["style"]; ok {
return fmt.Errorf("repeater style is not supported")
}
if typ != repeaterType {
for _, key := range repeaterOnlyKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is only valid on type: repeater", key)
}
}
return nil
}
for key := range values {
if !repeaterKeys[key] {
return fmt.Errorf("%s is not valid on type: repeater", key)
}
}
field.Span = "full"
if node, ok := values["prompt"]; ok {
prompt, err := nodeString(node)
if err != nil {
return fmt.Errorf("prompt: %w", err)
}
field.Prompt = prompt
}
formNode, ok := values["form"].(*ast.MappingNode)
if !ok {
return fmt.Errorf("a repeater needs form: with fields")
}
var fieldsNode ast.Node
for _, entry := range formNode.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("form: %w", err)
}
switch key {
case "fields":
fieldsNode = unwrapNode(entry.Value)
case "groups":
return fmt.Errorf("repeater groups are not supported")
default:
return fmt.Errorf("form: unknown field %s (a repeater form takes fields only)", key)
}
}
if fieldsNode == nil {
return fmt.Errorf("a repeater needs form: with fields")
}
nested, err := decodeFieldMapping(fieldsNode, nil, nil)
if err != nil {
return fmt.Errorf("repeater %s: %w", name, err)
}
if len(nested) == 0 {
return fmt.Errorf("repeater %s: form.fields is empty", name)
}
for _, inner := range nested {
if !repeaterNestedTypes[inner.Type] {
return fmt.Errorf("repeater %s: field %s: type %s is not supported in a repeater (want text, textarea, number, checkbox, switch, dropdown or richeditor)", name, inner.Name, inner.Type)
}
if inner.Tab != "" || inner.Context != nil || inner.Preset != nil {
return fmt.Errorf("repeater %s: field %s: tab, context and preset are not supported in a repeater row", name, inner.Name)
}
}
if err := checkDependsOn(nested, name); err != nil {
return err
}
field.Form = &RepeaterForm{Fields: nested}
for _, key := range []string{"maxItems", "minItems"} {
node, ok := values[key]
if !ok {
continue
}
n, err := nodeNonNegativeInt(node)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
if key == "maxItems" {
field.MaxItems = &n
} else {
field.MinItems = &n
}
}
if field.MinItems != nil && field.MaxItems != nil && *field.MinItems > *field.MaxItems {
return fmt.Errorf("minItems %d is greater than maxItems %d", *field.MinItems, *field.MaxItems)
}
if node, ok := values["titleFrom"]; ok {
title, err := nodeString(node)
if err != nil {
return fmt.Errorf("titleFrom: %w", err)
}
found := false
for _, inner := range nested {
found = found || inner.Name == title
}
if !found {
return fmt.Errorf("titleFrom %s is not a field of the repeater form", title)
}
field.TitleFrom = title
}
return nil
}
func nodeNonNegativeInt(node ast.Node) (int, error) {
n, ok := unwrapNode(node).(*ast.IntegerNode)
if !ok {
return 0, fmt.Errorf("want a non-negative integer")
}
var v int64
switch t := n.Value.(type) {
case int64:
v = t
case uint64:
if t > math.MaxInt32 {
return 0, fmt.Errorf("want a non-negative integer")
}
v = int64(t)
case int:
v = int64(t)
default:
return 0, fmt.Errorf("want a non-negative integer")
}
if v < 0 || v > math.MaxInt32 {
return 0, fmt.Errorf("want a non-negative integer")
}
return int(v), nil
}
// repeaterNames is the set of the controller form's repeater field names.
func repeaterNames(cc *CompiledController) map[string]bool {
if cc == nil || cc.Form == nil {
return nil
}
var out map[string]bool
for _, field := range cc.Form.Fields {
if field.Type == repeaterType {
if out == nil {
out = map[string]bool{}
}
out[field.Name] = true
}
}
return out
}
// listValue reports whether v is a JSON list.
func listValue(v any) bool {
_, ok := v.([]any)
return ok
}
// liftRepeaterValues decodes every submitted repeater value of the form in
// place, before the scalar projection: null becomes an empty list; a list
// must hold objects, and each row keeps only the nested field names, with its
// scalars coerced per nested type (number to a JSON number, checkbox and
// switch to a boolean, the rest to a string or null). A required nested value
// missing from a row is a 422 under field.index.nested; a row count above
// maxItems or below minItems is a 422 on the field; any other shape is a 422
// on the field. A required repeater needs at least one row when it is
// submitted, and on create also when it is not.
func liftRepeaterValues(ctx context.Context, cc *CompiledController, body map[string]any, op string, tr *phrasebook.Translator) error {
if cc == nil || cc.Form == nil || body == nil {
return nil
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
details := map[string]any{}
for _, field := range cc.Form.Fields {
if field.Type != repeaterType || !contextAllows(cc, field.Name, op) {
continue
}
raw, present := body[field.Name]
if !present {
if field.Required && op == "create" {
details[field.Name] = []string{"The " + field.Name + " field is required."}
}
continue
}
var items []any
switch v := raw.(type) {
case nil:
items = []any{}
case []any:
items = v
default:
details[field.Name] = []string{"The " + field.Name + " field must be a list of rows."}
continue
}
rows := make([]any, 0, len(items))
shapeOK := true
for i, item := range items {
row, ok := item.(map[string]any)
if !ok {
shapeOK = false
break
}
clean, errs := decodeRepeaterRow(field, i, row)
for key, msg := range errs {
details[key] = []string{msg}
}
rows = append(rows, clean)
}
if !shapeOK {
details[field.Name] = []string{"The " + field.Name + " field must be a list of rows."}
continue
}
switch {
case field.Required && len(rows) == 0:
details[field.Name] = []string{"The " + field.Name + " field is required."}
case field.MaxItems != nil && len(rows) > *field.MaxItems:
details[field.Name] = []string{trParams(ctx, tr, msgRepeaterMaxItems, map[string]string{"max": strconv.Itoa(*field.MaxItems)})}
case field.MinItems != nil && len(rows) < *field.MinItems:
details[field.Name] = []string{trParams(ctx, tr, msgRepeaterMinItems, map[string]string{"min": strconv.Itoa(*field.MinItems)})}
}
body[field.Name] = rows
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// trParams translates key with params, or keeps the key without a
// translator.
func trParams(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string {
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
// decodeRepeaterRow keeps the nested fields of one submitted row, coerced per
// type, and reports the row's errors keyed field.index.nested.
func decodeRepeaterRow(field FormField, index int, row map[string]any) (map[string]any, map[string]string) {
out := make(map[string]any, len(field.Form.Fields))
errs := map[string]string{}
for _, inner := range field.Form.Fields {
path := fmt.Sprintf("%s.%d.%s", field.Name, index, inner.Name)
value, err := coerceRowValue(inner.Type, row[inner.Name])
if err != nil {
errs[path] = "The " + path + " field " + err.Error() + "."
continue
}
if inner.Required && blankValue(value) {
errs[path] = "The " + path + " field is required."
}
out[inner.Name] = value
}
return out, errs
}
// coerceRowValue turns a submitted nested value into what a row stores for
// typ.
func coerceRowValue(typ string, v any) (any, error) {
switch typ {
case "checkbox", "switch":
switch t := v.(type) {
case nil:
return false, nil
case bool:
return t, nil
case string:
switch strings.ToLower(strings.TrimSpace(t)) {
case "1", "true", "on", "yes":
return true, nil
case "", "0", "false", "off", "no":
return false, nil
}
case json.Number:
return t.String() != "0", nil
case float64:
return t != 0, nil
}
return nil, fmt.Errorf("must be true or false")
case "number":
switch t := v.(type) {
case nil:
return nil, nil
case json.Number:
return t, nil
case float64:
return json.Number(strconv.FormatFloat(t, 'f', -1, 64)), nil
case int:
return json.Number(strconv.Itoa(t)), nil
case string:
s := strings.TrimSpace(t)
if s == "" {
return nil, nil
}
if _, err := strconv.ParseFloat(s, 64); err == nil {
return json.Number(s), nil
}
}
return nil, fmt.Errorf("must be a number")
default:
switch t := v.(type) {
case nil:
return nil, nil
case string:
return t, nil
case json.Number:
return t.String(), nil
case float64:
return strconv.FormatFloat(t, 'f', -1, 64), nil
case bool:
return strconv.FormatBool(t), nil
}
return nil, fmt.Errorf("must be text")
}
}
func blankValue(v any) bool {
switch t := v.(type) {
case nil:
return true
case string:
return strings.TrimSpace(t) == ""
}
return false
}
// repeaterRows is the projected rows of a repeater field, or false.
func repeaterRows(projected map[string]any, key string) ([]any, bool) {
v, ok := projected[key]
if !ok {
return nil, false
}
rows, ok := v.([]any)
return rows, ok
}
// sanitizeRepeaterRichEditors runs every nested richeditor value of the
// projected repeater rows through seaglass, so no row stores unsanitized
// HTML, and reports removals under field.index.nested.
func sanitizeRepeaterRichEditors(cc *CompiledController, projected map[string]any) []pluginNotice {
if cc == nil || cc.Form == nil {
return nil
}
var notices []pluginNotice
for _, field := range cc.Form.Fields {
if field.Type != repeaterType || field.Form == nil {
continue
}
rows, ok := repeaterRows(projected, fillKeyOf(cc, field.Name))
if !ok {
continue
}
for i, item := range rows {
row, ok := item.(map[string]any)
if !ok {
continue
}
for _, inner := range field.Form.Fields {
if inner.Type != richEditorType {
continue
}
text, ok := row[inner.Name].(string)
if !ok {
continue
}
clean, report := seaglass.SanitizeReport(text)
row[inner.Name] = clean
path := fmt.Sprintf("%s.%d.%s", field.Name, i, inner.Name)
for _, notice := range richEditorNotices(report) {
notices = append(notices, pluginNotice{field: path, notice: notice})
}
}
}
}
return notices
}
// hydrateRepeaterFields replaces the stored value of every repeater field in
// a record response with its list of rows: a lagoon.Jsonable column (or any
// driver.Valuer) is decoded from its JSON text, any other value through a
// JSON round trip. A missing or null value is an empty list.
func hydrateRepeaterFields(cc *CompiledController, data map[string]any) {
if cc == nil || cc.Form == nil || data == nil {
return
}
for _, field := range cc.Form.Fields {
if field.Type != repeaterType {
continue
}
value, ok := data[field.Name]
if !ok {
continue
}
data[field.Name] = storedRows(value)
}
}
func storedRows(value any) []any {
rows := []any{}
raw, ok := storedJSON(value)
if !ok {
return rows
}
var decoded []any
if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil {
return rows
}
return decoded
}
// storedJSON is the JSON text of a stored repeater value: a text column's
// string, a driver.Valuer's text (lagoon.Jsonable), or any other value
// marshaled. It is false for a missing or null value.
func storedJSON(value any) ([]byte, bool) {
switch t := value.(type) {
case nil:
return nil, false
case string:
return []byte(t), true
case *string:
if t == nil {
return nil, false
}
return []byte(*t), true
case driver.Valuer:
v, err := t.Value()
if err != nil || v == nil {
return nil, false
}
switch text := v.(type) {
case []byte:
return text, true
case string:
return []byte(text), true
}
return nil, false
}
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Pointer && rv.IsNil() {
return nil, false
}
b, err := json.Marshal(value)
return b, err == nil
}