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

@@ -1189,6 +1189,14 @@
"description": "FirstDay is the first day of the week, 0 (Sunday) to 6.",
"type": "integer"
},
"form": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.RepeaterForm"
}
],
"description": "Form holds the nested fields of a `type: repeater` field, one set per\nrow (D-07)."
},
"format": {
"description": "Format is a datepicker's WinterCMS (PHP date) display format;\nDisplayFormat is the same format in the SPA's moment-style tokens.",
"type": "string"
@@ -1218,6 +1226,10 @@
"description": "MaxFilesize is the largest accepted file in megabytes.",
"type": "number"
},
"maxItems": {
"description": "MaxItems and MinItems bound a repeater's row count, checked on save.",
"type": "integer"
},
"mimeTypes": {
"description": "MimeTypes are the allowed MIME patterns (or extensions) of a\nfileupload field.",
"items": {
@@ -1229,6 +1241,9 @@
"description": "MinDate and MaxDate bound a datepicker's calendar date (YYYY-MM-DD),\nchecked again on save.",
"type": "string"
},
"minItems": {
"type": "integer"
},
"mode": {
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime) or the\npermissioneditor mode (radio or checkbox).",
"type": "string"
@@ -1301,6 +1316,10 @@
],
"description": "ThumbOptions carries the preview thumbnail mode."
},
"titleFrom": {
"description": "TitleFrom names the nested field whose value titles a repeater row.",
"type": "string"
},
"translatable": {
"description": "Translatable is true when a plugin field type's value is a map of\ncontent locale to string.",
"type": "boolean"
@@ -2287,6 +2306,20 @@
],
"type": "object"
},
"cabana.RepeaterForm": {
"properties": {
"fields": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
}
},
"required": [
"fields"
],
"type": "object"
},
"cabana.RowAction": {
"properties": {
"label": {
@@ -4104,7 +4137,7 @@
},
"/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options": {
"get": {
"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[\u003csibling\u003e]` (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[\u003csibling\u003e]` (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 `\u003crepeater\u003e.\u003cfield\u003e` and `values[\u003csibling\u003e]` is the row's sibling value. A field that is not a dependsOn dropdown of the controller's form answers 404.",
"parameters": [
{
"description": "Vendor",
@@ -4134,7 +4167,7 @@
}
},
{
"description": "Dropdown field name",
"description": "Dropdown field name, or repeater.field for a dropdown in a repeater row",
"in": "path",
"name": "field",
"required": true,
@@ -4522,7 +4555,7 @@
},
"/{vendor}/{plugin}/{controller}/schema/form": {
"get": {
"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 `\u003cfield\u003e.\u003cindex\u003e.\u003cnested\u003e`.",
"parameters": [
{
"description": "Vendor",

View File

@@ -1577,7 +1577,7 @@ export interface paths {
};
/**
* 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.
*/
get: {
parameters: {
@@ -1590,7 +1590,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Dropdown field name */
/** @description Dropdown field name, or repeater.field for a dropdown in a repeater row */
field: string;
};
cookie?: never;
@@ -1896,7 +1896,7 @@ export interface paths {
};
/**
* 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>`.
*/
get: {
parameters: {
@@ -5331,6 +5331,11 @@ export interface components {
fill?: string[];
/** @description FirstDay is the first day of the week, 0 (Sunday) to 6. */
firstDay?: number;
/**
* @description Form holds the nested fields of a `type: repeater` field, one set per
* row (D-07).
*/
form?: components["schemas"]["cabana.RepeaterForm"];
/**
* @description Format is a datepicker's WinterCMS (PHP date) display format;
* DisplayFormat is the same format in the SPA's moment-style tokens.
@@ -5347,6 +5352,8 @@ export interface components {
maxFiles?: number;
/** @description MaxFilesize is the largest accepted file in megabytes. */
maxFilesize?: number;
/** @description MaxItems and MinItems bound a repeater's row count, checked on save. */
maxItems?: number;
/**
* @description MimeTypes are the allowed MIME patterns (or extensions) of a
* fileupload field.
@@ -5357,6 +5364,7 @@ export interface components {
* checked again on save.
*/
minDate?: string;
minItems?: number;
/**
* @description Mode is the fileupload mode (image or file, default file), the
* datepicker mode (date, datetime or time, default datetime) or the
@@ -5400,6 +5408,8 @@ export interface components {
tab?: string;
/** @description ThumbOptions carries the preview thumbnail mode. */
thumbOptions?: components["schemas"]["cabana.ThumbOptions"];
/** @description TitleFrom names the nested field whose value titles a repeater row. */
titleFrom?: string;
/**
* @description Translatable is true when a plugin field type's value is a map of
* content locale to string.
@@ -5705,6 +5715,9 @@ export interface components {
view: components["schemas"]["cabana.RelationPanel"];
viewForm?: components["schemas"]["cabana.FormField"][];
};
"cabana.RepeaterForm": {
fields: components["schemas"]["cabana.FormField"][];
};
"cabana.RowAction": {
label?: string;
name: string;