- 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
488 lines
14 KiB
Go
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
|
|
}
|