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

@@ -24,7 +24,7 @@ Use cases:
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are lifted before that drop: the default locale becomes the host scalar and the other locales are written through a published `cabana.TranslationWriter` inside the same permissioned save transaction. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies; only a `type: repeater` field's list of rows is exempt from the nested-value drop. Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are lifted before that drop: the default locale becomes the host scalar and the other locales are written through a published `cabana.TranslationWriter` inside the same permissioned save transaction. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
@@ -39,7 +39,8 @@ Use cases:
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
- Rich text: `type: richeditor` edits HTML in a host text column (the admin SPA shows a WYSIWYG editor). It takes the common field keys plus `size`; any other key fails boot, and settings and relation forms refuse the type (supported in controller forms only). On create and update a submitted value must be a string or null (anything else is a 422 on the field) and is sanitized through [seaglass](../seaglass/README.md) before it is filled: HTML the policy keeps whole is stored byte for byte, otherwise the clean HTML is stored and what was removed is reported in `meta.notices` under the field (`backend::lang.richeditor.removed_elements`, `removed_attributes`, `removed_comments` with the count and names, or `markup_changed`).
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); the form schema carries `dependsOn`. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
- Repeater: `type: repeater` edits a JSON list in one column (`lagoon.Jsonable`) with a nested `form.fields` of `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or `dependsOn` a field of the same row) and `richeditor` fields, plus `prompt`, `minItems`, `maxItems` and `titleFrom` (`cabana.RepeaterForm` in the schema; the span is always full). Other nested types, a nested repeater, nested `tab`, `context` or `preset`, and WinterCMS's `groups` and `style` fail boot; settings and relation forms refuse the type. A save decodes the rows before the projection: null is an empty list, anything but a list of objects is a 422 on the field, each row keeps only the nested names with scalars coerced per type, a required nested value missing in a row is a 422 under `field.index.nested`, the row bounds are 422s on the field (`repeater.max_items`, `repeater.min_items`), nested richeditor values are sanitized with notices under the row path, and nested dependent dropdowns are rechecked. Record responses carry the list of rows.
- Dependent dropdowns: `dependsOn: <field>` on a `type: dropdown` field names a sibling with a scalar value (the same form, or the same repeater row); the dropdown takes no `options` and its controller implements `pact.DependentOptionsProvider`, else boot fails. GET `.../fields/{field}/dependent-options?values[<sibling>]=<value>` answers `cabana.AdminDependentOptionsResult` (`applies` and the localized `options` with their `depth`); `{field}` is `repeater.field` for a dropdown in a repeater row; the form schema carries `dependsOn`. Every save rechecks the value with the submitted sibling value (per row in a repeater): a value that does not apply is stored null, one outside the options is a 422 `form.dependent_invalid` on the field. A menu or sitemap controller returns `signpost.ReferenceOptions` here, the one admin binding of the [signpost](../signpost/README.md) menu item type registry. Settings and relation forms refuse `dependsOn` (supported in controller forms only).
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
@@ -233,6 +234,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
| `cabana.AdminDependentOptionsResult` / `cabana.AdminDependentOption` | Answer of the dependent-options route: `applies` and the options, each with `value`, localized `label` and `depth`. |
| `cabana.AdminDependentOptions` | Swag annotation of the dependent-options route. |
| `cabana.RepeaterForm` | The `form` of a `type: repeater` field in a form schema: the fields of one row. |
| `cabana.FormPreview` | The `preview` object of a form schema: present when the form has a preview screen; `headerPartial` names its status hint partial. |
| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. |
| `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. |

View File

@@ -428,7 +428,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @Summary Admin form schema
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route.
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys). A `type: dropdown` field with `dependsOn` names the sibling field whose value selects its options and carries no options: they come from the field's dependent-options route. A `type: richeditor` field's value is a string of HTML, sanitized on every save; what the sanitizer removed is reported in the save response's `meta.notices`. A `type: repeater` field carries `form.fields` (the fields of one row), `prompt`, `maxItems`, `minItems` and `titleFrom`; its value in a record response and in a save body is a list of row objects keyed by the nested field names, and a row's errors are keyed `<field>.<index>.<nested>`.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -484,14 +484,14 @@ func AdminFieldOptions() {}
// AdminDependentOptions documents the dependent dropdown options route (D-08).
//
// @Summary Dependent dropdown options
// @Description Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[<sibling>]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. A field that is not a dependsOn dropdown of the controller's form answers 404.
// @Description Choices of a `type: dropdown` field with `dependsOn`, from the controller's pact.DependentOptionsProvider, for the value of the sibling field the dropdown depends on. The sibling's value is passed as the query parameter `values[<sibling>]` (for example `values[type]=acme-post`); a missing or empty value is null. `applies` is false when that value takes no choice at all (the field then stores null) and `options` is then empty; with `applies` true an empty `options` means there is nothing to choose yet. `depth` is the nesting level of a choice taken from a tree (0 at the top); labels are localized. For a dropdown inside a repeater row, {field} is `<repeater>.<field>` and `values[<sibling>]` is the row's sibling value. A field that is not a dependsOn dropdown of the controller's form answers 404.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param field path string true "Dropdown field name"
// @Param field path string true "Dropdown field name, or repeater.field for a dropdown in a repeater row"
// @Success 200 {object} Envelope[AdminDependentOptionsResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope

View File

@@ -38,11 +38,13 @@ type companionsLink struct {
Reference *string `gorm:"column:reference"`
// Description is the richeditor field.
Description *string `gorm:"column:description"`
// Rows is the repeater field.
Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"`
}
func (companionsLink) TableName() string { return "cabana_companions_links" }
func (companionsLink) Fillable() []string {
return []string{"title", "type", "reference", "description"}
return []string{"title", "type", "reference", "description", "rows"}
}
func (companionsLink) Rules() map[string]string { return map[string]string{"title": "required"} }
@@ -299,3 +301,76 @@ func TestCompanionsRichEditorSmoke(t *testing.T) {
t.Fatalf("non-string description status=%d body=%s", rec.Code, rec.Body.String())
}
}
// TestCompanionsRepeaterSmoke checks the D-07 repeater and the D-08 save
// recheck: rows keep only their nested fields, required applies per row,
// nested richeditor values are sanitized, and a dependent value outside its
// options is a 422, in a row and at the top level.
func TestCompanionsRepeaterSmoke(t *testing.T) {
e := newCompanionsEnv(t)
rows := []any{
map[string]any{"title": "First", "body": `<p onclick="x()">One</p>`, "type": "acme-page", "reference": "2", "extra": "dropped"},
map[string]any{"title": "Second", "type": "acme-list", "reference": "1"},
}
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "type": "acme-page", "reference": "1", "rows": rows}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
created := decodeCompanionsRecord(t, rec.Body.Bytes())
id := dataID(t, rec.Body.Bytes())
var stored companionsLink
if err := e.db.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
got := stored.Rows.Get()
if len(got) != 2 {
t.Fatalf("stored rows = %#v", got)
}
for _, row := range got {
if _, extra := row["extra"]; extra || len(row) != 4 {
t.Fatalf("row keeps keys outside the nested form: %#v", row)
}
}
if got[0]["body"] != "<p>One</p>" || got[0]["reference"] != "2" || got[1]["reference"] != nil {
t.Fatalf("stored rows = %#v (body sanitized, reference kept, reference of a type without references null)", got)
}
noticed := false
for _, n := range created.Meta.Notices {
noticed = noticed || n.Field == "rows.0.body"
}
if !noticed {
t.Fatalf("no notice under rows.0.body: %+v", created.Meta.Notices)
}
if list, ok := created.Data["rows"].([]any); !ok || len(list) != 2 {
t.Fatalf("record response rows = %#v", created.Data["rows"])
}
expect422 := func(t *testing.T, body map[string]any, key string) {
t.Helper()
rec := e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), body, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+key+`"`) {
t.Fatalf("want 422 on %s, status=%d body=%s", key, rec.Code, rec.Body.String())
}
}
expect422(t, map[string]any{"title": "Rows", "rows": []any{map[string]any{"title": "ok"}, map[string]any{"body": "<p>x</p>"}}}, "rows.1.title")
expect422(t, map[string]any{"title": "Rows", "rows": []any{map[string]any{"title": "ok", "type": "acme-page", "reference": "99"}}}, "rows.0.reference")
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/companions/links/%d", id), map[string]any{"title": "Rows", "type": "acme-page", "reference": "99"}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"reference":["Choose one of the listed options."]`) && !strings.Contains(rec.Body.String(), `"reference":["Wybierz jedną z dostępnych opcji."]`) {
t.Fatalf("top-level dependent value status=%d body=%s", rec.Code, rec.Body.String())
}
expect422(t, map[string]any{"title": "Rows", "rows": "not a list"}, "rows")
six := make([]any, 6)
for i := range six {
six[i] = map[string]any{"title": fmt.Sprint(i)}
}
expect422(t, map[string]any{"title": "Rows", "rows": six}, "rows")
shown := e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/%d", id), nil, true)
if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[{`) {
t.Fatalf("show rows status=%d body=%s", shown.Code, shown.Body.String())
}
opts := e.send(t, http.MethodGet, dependentOptionsPath("rows.reference", "acme-page"), nil, true)
if opts.Code != http.StatusOK || !strings.Contains(opts.Body.String(), `"applies":true`) {
t.Fatalf("nested dependent options status=%d body=%s", opts.Code, opts.Body.String())
}
}

View File

@@ -168,9 +168,15 @@ func ProjectWritableFields(cc *CompiledController, body map[string]any) map[stri
}
allowed[field.Name] = field.FillKey
}
// A repeater's value is a list of rows (D-07): only those fields are
// exempt from the nested-value refusal, and only for a list.
repeaters := repeaterNames(cc)
for key, val := range body {
fillKey, ok := allowed[key]
if !ok || nestedValue(val) {
if !ok {
continue
}
if nestedValue(val) && !(repeaters[key] && listValue(val)) {
continue
}
out[fillKey] = val
@@ -557,6 +563,7 @@ func (actionConflict) Error() string { return "cabana: action does not apply" }
// every permissioneditor field as an object (D-16).
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
data := projectRecord(cc, model)
hydrateRepeaterFields(cc, data)
meta, err := projectRelationFields(ctx, tx, cc, model, data)
if err != nil {
return RecordResult{}, err
@@ -612,6 +619,12 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err := checkRichEditorValues(cc, in.Body, op); err != nil {
return RecordResult{}, err
}
// D-07: repeater rows are decoded in place (only nested keys, coerced
// scalars, per-row required, row bounds) before the projection, which
// lets a repeater's list through.
if err := liftRepeaterValues(ctx, cc, in.Body, op, s.tr); err != nil {
return RecordResult{}, err
}
var (
result RecordResult
notices []pluginNotice
@@ -638,6 +651,12 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
}
// D-10: every richeditor value is sanitized before Fill sees it.
notices = append(decoded.notices, sanitizeRichEditors(cc, projected)...)
notices = append(notices, sanitizeRepeaterRichEditors(cc, projected)...)
// D-08: a dependent dropdown's value must be among the options for
// its sibling's value, top level and in every repeater row.
if err := checkDependentValues(ctx, cc, s.tr, target, projected, op); err != nil {
return err
}
// The Form hooks read what this save writes for plugin field types.
ctx = withFieldWrites(ctx, decoded.writes)
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
@@ -1096,7 +1115,8 @@ func mergedRules(ctx context.Context, cc *CompiledController, model any, op stri
// Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill. A virtual
// field is checked against its submitted value.
if field.Required && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
// A repeater's required is checked on its rows (liftRepeaterValues).
if field.Required && field.Type != repeaterType && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
out[field.Name] = mergeRequired(out[field.Name])
}
}
@@ -1260,7 +1280,7 @@ func scalarFormField(typ string) bool {
// valueFormField reports whether a controller form field binds to its model
// column: a scalar built-in type or a plugin field type.
func valueFormField(field FormField) bool {
return scalarFormField(field.Type) || field.fieldType != nil
return scalarFormField(field.Type) || field.fieldType != nil || field.Type == repeaterType
}
func protectedFillKey(key string) bool {

View File

@@ -2,12 +2,16 @@ package cabana
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"reflect"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
)
@@ -78,6 +82,9 @@ func usesDependsOn(fields []FormField) bool {
if field.DependsOn != "" {
return true
}
if field.Form != nil && usesDependsOn(field.Form.Fields) {
return true
}
}
return false
}
@@ -98,6 +105,13 @@ func requireDependentProvider(ctl pact.AdminController, fields []FormField) erro
if field.DependsOn != "" {
return fmt.Errorf("field %s: dependsOn needs controller %s to implement pact.DependentOptionsProvider", field.Name, ctl.ID())
}
if field.Form != nil {
for _, inner := range field.Form.Fields {
if inner.DependsOn != "" {
return fmt.Errorf("field %s.%s: dependsOn needs controller %s to implement pact.DependentOptionsProvider", field.Name, inner.Name, ctl.ID())
}
}
}
}
return nil
}
@@ -106,8 +120,8 @@ func requireDependentProvider(ctl pact.AdminController, fields []FormField) erro
// that only an admin controller's save path supports: its checks run in the
// controller save and nowhere else.
func controllerFormOnly(field FormField) error {
if field.Type == richEditorType {
return fmt.Errorf("richeditor is supported in controller forms only")
if field.Type == richEditorType || field.Type == repeaterType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
}
if field.DependsOn != "" {
return fmt.Errorf("dependsOn is supported in controller forms only")
@@ -117,13 +131,29 @@ func controllerFormOnly(field FormField) error {
// dependentField is the dependsOn dropdown name of the controller's form, or
// false.
func dependentField(cc *CompiledController, name string) (FormField, bool) {
// dependentField is the dependsOn dropdown of the controller's form named
// by path: a top-level field name, or repeater.nested for a dropdown of a
// repeater row. The returned field carries the path as its Name.
func dependentField(cc *CompiledController, path string) (FormField, bool) {
if cc == nil || cc.Form == nil {
return FormField{}, false
}
outer, inner, nested := strings.Cut(path, ".")
for _, field := range cc.Form.Fields {
if field.Name == name && field.DependsOn != "" {
return field, true
if field.Name != outer {
continue
}
if !nested {
return field, field.DependsOn != ""
}
if field.Form == nil {
return FormField{}, false
}
for _, sub := range field.Form.Fields {
if sub.Name == inner && sub.DependsOn != "" {
sub.Name = path
return sub, true
}
}
}
return FormField{}, false
@@ -143,6 +173,8 @@ func dependentValue(v any) any {
return t
case bool:
return strconv.FormatBool(t)
case json.Number:
return t.String()
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case int:
@@ -219,3 +251,132 @@ func (s *service) dependentOptionsRoute(w http.ResponseWriter, r *http.Request)
WriteData(w, http.StatusOK, out, map[string]any{"locale": locale})
})
}
// msgDependentInvalid is the 422 message of a dependent dropdown value that
// is not among the options for the submitted sibling value (backend::lang).
const msgDependentInvalid = "backend::lang.form.dependent_invalid"
// checkDependentValues rechecks every dependsOn dropdown of a save (D-08):
// at the top level when the dropdown or its sibling is written, and in every
// row of a written repeater. The controller's provider is asked with the
// sibling's submitted value (the stored one when only the dropdown is
// written). When the answer does not apply, the dropdown is stored null; when
// it applies, a value that is not among the options is a 422
// form.dependent_invalid on the field (field.index.nested in a row). Null is
// accepted here; required is checked by the rules.
func checkDependentValues(ctx context.Context, cc *CompiledController, tr *phrasebook.Translator, target any, projected map[string]any, op string) error {
if cc == nil || cc.Form == nil || !usesDependsOn(cc.Form.Fields) {
return nil
}
provider, ok := cc.Controller.(pact.DependentOptionsProvider)
if !ok || provider == nil {
return fmt.Errorf("cabana: controller %s has no DependentOptions", controllerID(cc))
}
cache := map[string]pact.DependentOptionSet{}
ask := func(path, sibling string, value any) (pact.DependentOptionSet, error) {
v := dependentValue(value)
key := path + "\x00" + fmt.Sprint(v)
if set, ok := cache[key]; ok {
return set, nil
}
set, err := provider.DependentOptions(ctx, path, map[string]any{sibling: v})
if err != nil {
return pact.DependentOptionSet{}, err
}
cache[key] = set
return set, nil
}
locCtx := towel.WithLocale(ctx, schemaLocale(ctx, tr))
message := translateKey(locCtx, tr, msgDependentInvalid)
details := map[string]any{}
for _, field := range cc.Form.Fields {
if field.DependsOn != "" && contextAllows(cc, field.Name, op) {
key, siblingKey := fillKeyOf(cc, field.Name), fillKeyOf(cc, field.DependsOn)
value, hasValue := projected[key]
sibling, hasSibling := projected[siblingKey]
if hasValue || hasSibling {
if !hasValue {
value = modelValue(target, key)
}
if !hasSibling {
sibling = modelValue(target, siblingKey)
}
set, err := ask(field.Name, field.DependsOn, sibling)
if err != nil {
return err
}
switch {
case !set.Applies:
projected[key] = nil
case !amongOptions(set, value):
details[field.Name] = []string{message}
}
}
}
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.DependsOn == "" {
continue
}
set, err := ask(field.Name+"."+inner.Name, inner.DependsOn, row[inner.DependsOn])
if err != nil {
return err
}
switch {
case !set.Applies:
row[inner.Name] = nil
case !amongOptions(set, row[inner.Name]):
details[fmt.Sprintf("%s.%d.%s", field.Name, i, inner.Name)] = []string{message}
}
}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// amongOptions reports whether value is null or one of the set's option
// values.
func amongOptions(set pact.DependentOptionSet, value any) bool {
v := dependentValue(value)
if v == nil {
return true
}
text, _ := v.(string)
for _, opt := range set.Options {
if opt.Value == text {
return true
}
}
return false
}
// modelValue is the current value of the model's column, dereferenced; nil
// when the column is missing or a nil pointer.
func modelValue(model any, column string) any {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return nil
}
v = v.Elem()
}
f := fieldByColumn(v, column)
if !f.IsValid() || !f.CanInterface() {
return nil
}
return derefValue(f.Interface())
}

View File

@@ -66,7 +66,13 @@ const (
func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != "fileupload" {
for _, key := range fileuploadKeys {
if key == "prompt" && typ == repeaterType {
continue // a repeater's add button text
}
if _, ok := values[key]; ok {
if key == "prompt" {
return fmt.Errorf("prompt is only valid on type: fileupload or repeater")
}
return fmt.Errorf("%s is only valid on type: fileupload", key)
}
}

View File

@@ -0,0 +1,487 @@
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
}

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 {

View File

@@ -437,7 +437,9 @@ func constrainFileID(g pact.Router) {
func constrainNested(g pact.Router) {
constrainController(g)
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
// segment admits one dot for the repeater.nested path of a dependent
// dropdown inside a repeater row (fields/{field}/dependent-options).
g.Where("segment", `[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?`)
// name admits a hyphen for the literal dependent-options segment; a
// relation or file field name with one matches nothing and answers 404.
g.Where("name", "[A-Za-z_][A-Za-z0-9_-]*")

View File

@@ -261,6 +261,14 @@ type FormField struct {
// whose value selects a dropdown's options; the options come from the
// dependent-options route (D-08).
DependsOn string `json:"dependsOn,omitempty"`
// Form holds the nested fields of a `type: repeater` field, one set per
// row (D-07).
Form *RepeaterForm `json:"form,omitempty"`
// MaxItems and MinItems bound a repeater's row count, checked on save.
MaxItems *int `json:"maxItems,omitempty"`
MinItems *int `json:"minItems,omitempty"`
// TitleFrom names the nested field whose value titles a repeater row.
TitleFrom string `json:"titleFrom,omitempty"`
// Widget is the custom-element tag of a `type: widget` field (D-06).
Widget string `json:"widget,omitempty"`
// Action names the controller action the widget runs (pact.HasAdminActions).
@@ -341,6 +349,13 @@ type FormField struct {
fieldType *compiledFieldType `json:"-"`
}
// RepeaterForm is a repeater's `form`: the fields of one row, in file order.
// Only text, textarea, number, checkbox, switch, dropdown and richeditor
// fields are allowed in a row.
type RepeaterForm struct {
Fields []FormField `json:"fields"`
}
// FieldPreset is a text or mltext field's `preset`: Field names the text or
// mltext field of the same form whose value it follows, and Type is how the
// value is taken over, slug (lower-case ASCII with hyphens) or exact (the same

View File

@@ -16,3 +16,26 @@ fields:
label: Description
type: richeditor
size: large
rows:
label: Rows
type: repeater
prompt: Add row
titleFrom: title
maxItems: 5
form:
fields:
title:
label: Title
type: text
required: true
body:
label: Body
type: richeditor
type:
label: Type
type: dropdown
options: typeOptions
reference:
label: Reference
type: dropdown
dependsOn: type

View File

@@ -102,6 +102,7 @@ form:
action_done: Action completed.
action_stale: This action no longer applies to this record. The page has been refreshed.
forbidden: You do not have permission to make this change. Nothing was saved.
dependent_invalid: Choose one of the listed options.
relation:
add: Add
link: Link
@@ -334,3 +335,6 @@ richeditor:
one: ":count HTML comment was removed"
other: ":count HTML comments were removed"
markup_changed: The HTML filter changed some markup.
repeater:
max_items: Remove items until there are at most :max.
min_items: Add items until there are at least :min.

View File

@@ -112,6 +112,7 @@ form:
action_done: Akcja została wykonana.
action_stale: Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona.
forbidden: Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane.
dependent_invalid: Wybierz jedną z dostępnych opcji.
relation:
add: Dodaj
link: Dołącz
@@ -362,3 +363,6 @@ richeditor:
many: "Usunięto :count komentarzy HTML"
other: "Usunięto :count komentarza HTML"
markup_changed: Filtr HTML zmienił część znaczników.
repeater:
max_items: Usuń pozycje, aby zostało ich najwyżej :max.
min_items: Dodaj pozycje, aby było ich co najmniej :min.