feat(19.6.1-02): field trigger and admin build

- fields.yaml trigger (action disable|hide, field, condition checked|unchecked) compiled to FormField.Trigger
- boot errors for malformed triggers, controlling fields that are not a switch or checkbox, and triggers in repeater rows, settings and relation forms
- Administrators permissions overrides are disabled while Superuser is on (D-06)
- SPA: trigger.ts (triggerState, applyTriggers), FORM_TRIGGER_NOTES, the trigger note in FormField, FormView tabs/banner/payload after triggers, PreviewView hide only
- regenerated admin OpenAPI and schema.d.ts; rebuilt modules/boardwalk/dist; docs and README
This commit is contained in:
Jakub Zych
2026-10-22 11:43:58 +02:00
parent 705f94d9e5
commit 7091d09699
27 changed files with 501 additions and 35 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-Dj7oK1ZY.js"></script>
<script type="module" crossorigin src="./assets/index-BW1fufT0.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-GIpW_WLy.css">
</head>
<body>

View File

@@ -38,6 +38,7 @@ Use cases:
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Plugin field types: a plugin implementing `pact.HasFormFieldTypes` registers admin form field types under short aliases that any controller's `fields.yaml` uses as `type:`. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and `CheckOptions`. A save runs the type's `Decode` inside the save transaction after the record is loaded: the decoded value is stored, a `pact.FormFieldError` is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's `meta.notices` (`cabana.FieldNotice`), which is absent when no type reported anything. Show and save responses serve the value through the type's `Load`. A translatable type's value is a locale map stored through the same lift, `cabana.TranslationWriter` and hydrate path as `mlmarkdown`, decoded and loaded per locale. On update, a value equal to the one the form was served for that field and locale is neither decoded nor written. Form hooks read what a save wrote through `cabana.FieldWritesFromContext`. A list column may name a plugin alias as its `type` and shows the type's `ListValue`. The cabana-owned upload route hands an element's file to the type's `Upload`. The form schema carries `element`, `translatable` and `typeOptions` for such a field. Settings and relation forms refuse plugin field types (supported in controller forms only). See [Plugin field types](../../docs/backend/field-types.md).
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Field triggers: fields.yaml `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`) makes the admin disable or hide a field while a switch or checkbox of the same form is on or off; the schema carries it as `trigger` (`cabana.FieldTrigger`), a disabled or hidden field is left out of the save body, and an invalid trigger, or one in a repeater row, settings form or relation form, stops boot.
- 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. A controller that also implements `cabana.PermissionRecordLockProvider` fixes some values of one stored record (a role's code-declared grants, an administrator's own overrides): the record response carries them as `meta.permissionLocks`, the admin shows them locked with their note and never sends them, and a save keeps their stored values. 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`).
@@ -305,6 +306,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.FieldWritesFromContext` | The values a create or update save writes for the form's plugin field types, per field and locale, from the context of a Form hook; untouched values are not listed. A copy; false outside a save. |
| `cabana.WithFieldWrites` | Puts field writes on a context as a save does, for tests of Form hooks that read `cabana.FieldWritesFromContext`. |
| `cabana.FieldUploadResponse` / `cabana.AdminFieldUpload` | Answer (`url`) and swag annotation of the field upload route. |
| `cabana.FieldTrigger` | A field's fields.yaml `trigger`: `Action` (disable or hide), `Field` (a switch or checkbox of the same form) and `Condition` (checked or unchecked); served on `FormField.Trigger`. |
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
| `cabana.PermissionRecordLock` | One permission a stored record fixes: `Code`, the `Value` the editor shows (1, -1, or 0 for inherit) and a `Note` (phrase key or text). |

View File

@@ -497,7 +497,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. 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>`.
// @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. `trigger` ({action, field, condition}) disables (action disable: shown read-only) or hides (action hide) the field while the switch or checkbox `field` of the same form is checked or unchecked; the admin leaves such a field out of the save body and the server keeps its stored value, so the trigger is a display rule only. 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

View File

@@ -356,8 +356,18 @@ func TestAdministratorsFormSmoke(t *testing.T) {
peer, _ := env.admin(t, "form-peer", false, `{"backend.manage_users":1}`)
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), limitedToken, nil, "")
schema := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", limitedToken, nil)
if strings.Contains(fmt.Sprint(schema["data"]), "is_superuser") {
t.Fatal("the form schema shows is_superuser to a non-superuser")
// The permissions field's trigger still names is_superuser (D-21: a
// controlling field missing from the schema counts as off); the switch
// itself must be gone.
schemaData, _ := schema["data"].(map[string]any)
schemaFields, _ := schemaData["fields"].([]any)
if len(schemaFields) == 0 {
t.Fatalf("form schema = %v", schema["data"])
}
for _, item := range schemaFields {
if field, _ := item.(map[string]any); field["name"] == "is_superuser" {
t.Fatal("the form schema shows is_superuser to a non-superuser")
}
}
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken,
map[string]any{"login": peer.Login, "email": peer.Email, "is_superuser": false}, "Only a superuser")

View File

@@ -59,3 +59,7 @@ fields:
mode: radio
span: full
tab: backend::lang.user.permissions
trigger:
action: disable
field: is_superuser
condition: checked

View File

@@ -127,9 +127,10 @@ func controllerFormOnly(field FormField) error {
}
// settingsFormOnly refuses, on a settings form, a field only an admin
// controller's save path supports: a repeater and a dependent dropdown. A
// richeditor is let through, because the settings Put runs the same
// sanitizer gate as the controller save.
// controller's save path supports: a repeater and a dependent dropdown, and a
// trigger, which the admin evaluates on controller forms only. A richeditor
// is let through, because the settings Put runs the same sanitizer gate as
// the controller save.
func settingsFormOnly(field FormField) error {
if field.Type == repeaterType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
@@ -137,6 +138,9 @@ func settingsFormOnly(field FormField) error {
if field.DependsOn != "" {
return fmt.Errorf("dependsOn is supported in controller forms only")
}
if field.Trigger != nil {
return fmt.Errorf("trigger is supported in controller forms only")
}
return nil
}

View File

@@ -110,6 +110,9 @@ func compileRepeater(name, typ string, values map[string]ast.Node, field *FormFi
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 inner.Trigger != nil {
return fmt.Errorf("repeater %s: field %s: trigger is not supported in a repeater row", name, inner.Name)
}
}
if err := checkDependsOn(nested, name); err != nil {
return err

View File

@@ -0,0 +1,101 @@
package cabana
import (
"fmt"
"github.com/goccy/go-yaml/ast"
)
// FieldTrigger is a field's fields.yaml `trigger` (D-21): the admin disables
// or hides the field while a switch or checkbox of the same form is checked
// or unchecked. Action is disable or hide, Field the controlling field's
// name, Condition checked or unchecked. It is a display rule only: the
// server never trusts it, and the admin leaves a disabled or hidden field
// out of the save body, so the stored value is kept.
type FieldTrigger struct {
Action string `json:"action"`
Field string `json:"field"`
Condition string `json:"condition"`
}
// triggerKeys are the sub-keys a trigger mapping must hold, each exactly once.
var triggerKeys = []string{"action", "field", "condition"}
// compileTrigger decodes a field's `trigger`: a mapping with exactly action
// (disable or hide), field (an identifier) and condition (checked or
// unchecked). The controlling field is checked against the form in
// checkTriggers.
func compileTrigger(values map[string]ast.Node, field *FormField) error {
node, ok := values["trigger"]
if !ok {
return nil
}
mapping, ok := node.(*ast.MappingNode)
if !ok {
return fmt.Errorf("trigger must be a mapping with action, field and condition")
}
got := map[string]string{}
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("trigger: %w", err)
}
switch key {
case "action", "field", "condition":
default:
return fmt.Errorf("trigger: unknown key %s (want action, field and condition)", key)
}
if _, dup := got[key]; dup {
return fmt.Errorf("trigger: duplicate key %s", key)
}
value, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("trigger: %s: %w", key, err)
}
got[key] = value
}
for _, key := range triggerKeys {
if _, ok := got[key]; !ok {
return fmt.Errorf("trigger: %s is required", key)
}
}
trigger := FieldTrigger{Action: got["action"], Field: got["field"], Condition: got["condition"]}
if trigger.Action != "disable" && trigger.Action != "hide" {
return fmt.Errorf("trigger: action %s is not supported (want disable or hide)", trigger.Action)
}
if trigger.Condition != "checked" && trigger.Condition != "unchecked" {
return fmt.Errorf("trigger: condition %s is not supported (want checked or unchecked)", trigger.Condition)
}
if !identifier(trigger.Field) {
return fmt.Errorf("trigger: field %q is not a field name", trigger.Field)
}
field.Trigger = &trigger
return nil
}
// checkTriggers checks every trigger of a controller form against its
// fields: the controlling field must be another field of the same form, of
// type switch or checkbox (a virtual switch counts).
func checkTriggers(fields []FormField) error {
types := make(map[string]string, len(fields))
for _, field := range fields {
types[field.Name] = field.Type
}
for _, field := range fields {
if field.Trigger == nil {
continue
}
name := field.Trigger.Field
if name == field.Name {
return fmt.Errorf("field %s: trigger field names the field itself", field.Name)
}
typ, ok := types[name]
if !ok {
return fmt.Errorf("field %s: trigger field %s is not a field of this form", field.Name, name)
}
if typ != "switch" && typ != "checkbox" {
return fmt.Errorf("field %s: trigger field %s must be a switch or checkbox, not type %s", field.Name, name, typ)
}
}
return nil
}

View File

@@ -0,0 +1,107 @@
package cabana
import (
"context"
"strings"
"testing"
"testing/fstest"
)
type triggerController struct{}
func (triggerController) ID() string { return "acme.trig.items" }
func (triggerController) ModelName() string { return "Item" }
func (triggerController) ConfigDir() string { return "controllers/items" }
func compileTriggerForm(fields string) (*FormSchema, error) {
fsys := fstest.MapFS{
"controllers/items/config_form.yaml": &fstest.MapFile{Data: []byte("form: ~/plugins/acme/trig/models/item/fields.yaml\nmodelClass: Item\n")},
"models/item/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
return CompileForm("acme.trig", triggerController{}, fsys)
}
func TestFieldTriggerSmoke(t *testing.T) {
base := "fields:\n is_admin:\n type: switch\n title:\n type: text\n"
valid := base + " notes:\n type: textarea\n trigger:\n action: disable\n field: is_admin\n condition: checked\n"
t.Run("a valid trigger compiles and reaches the schema", func(t *testing.T) {
schema, err := compileTriggerForm(valid)
if err != nil {
t.Fatal(err)
}
view, err := schema.Localize(context.Background(), nil, nil)
if err != nil {
t.Fatal(err)
}
var got *FieldTrigger
for _, field := range view.Fields {
if field.Name == "notes" {
got = field.Trigger
}
}
if got == nil || *got != (FieldTrigger{Action: "disable", Field: "is_admin", Condition: "checked"}) {
t.Fatalf("notes trigger = %+v", got)
}
if got == schema.Fields[2].Trigger {
t.Fatal("the localized view shares the cached trigger")
}
})
trig := func(body string) string { return base + " notes:\n type: textarea\n trigger:\n" + body }
for _, tc := range []struct{ name, fields, want string }{
{"unknown action", trig(" action: show\n field: is_admin\n condition: checked\n"), "action show is not supported"},
{"unknown condition", trig(" action: hide\n field: is_admin\n condition: value[1]\n"), "condition value[1] is not supported"},
{"extra sub-key", trig(" action: hide\n field: is_admin\n condition: checked\n value: 1\n"), "unknown key value"},
{"missing field", trig(" action: hide\n condition: checked\n"), "field is required"},
{"not a mapping", base + " notes:\n type: textarea\n trigger: is_admin\n", "trigger must be a mapping"},
{"self reference", base + " notes:\n type: checkbox\n trigger:\n action: hide\n field: notes\n condition: checked\n", "names the field itself"},
{"unknown controlling field", trig(" action: hide\n field: active\n condition: checked\n"), "trigger field active is not a field of this form"},
{"text controlling field", trig(" action: hide\n field: title\n condition: unchecked\n"), "trigger field title must be a switch or checkbox"},
{"in a repeater row", base + " rows:\n type: repeater\n form:\n fields:\n flag:\n type: checkbox\n notes:\n type: text\n trigger:\n action: hide\n field: flag\n condition: checked\n", "trigger is not supported in a repeater row"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := compileTriggerForm(tc.fields)
if err == nil {
t.Fatalf("compiled; want an error containing %q", tc.want)
}
msg := err.Error()
if !strings.Contains(msg, tc.want) || !strings.Contains(msg, "trigger") || !strings.Contains(msg, "notes") {
t.Fatalf("err = %v, want it to name notes, trigger and %q", err, tc.want)
}
if !strings.Contains(msg, "acme.trig.items") {
t.Fatalf("err = %v, want the controller named", err)
}
})
}
t.Run("settings and relation forms refuse trigger", func(t *testing.T) {
field := FormField{Name: "notes", Type: "text", Trigger: &FieldTrigger{Action: "hide", Field: "flag", Condition: "checked"}}
if err := settingsFormOnly(field); err == nil || !strings.Contains(err.Error(), "trigger") {
t.Fatalf("settingsFormOnly = %v", err)
}
if err := controllerFormOnly(field); err == nil || !strings.Contains(err.Error(), "trigger") {
t.Fatalf("controllerFormOnly = %v", err)
}
})
t.Run("the Administrators form disables overrides while Superuser is on", func(t *testing.T) {
reg, err := compileWithScreens(t, nil)
if err != nil {
t.Fatal(err)
}
cc, ok := reg.Get(AdministratorsControllerID)
if !ok || cc.Form == nil {
t.Fatal("no administrators form")
}
for _, field := range cc.Form.Fields {
if field.Name == usersPermissionsField {
if field.Trigger == nil || *field.Trigger != (FieldTrigger{Action: "disable", Field: usersSuperuserField, Condition: "checked"}) {
t.Fatalf("permissions trigger = %+v", field.Trigger)
}
return
}
}
t.Fatal("administrators form has no permissions field")
})
}

View File

@@ -45,7 +45,7 @@ var (
"thumbOptions": {}, "useCaption": {}, "prompt": {},
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
"preset": {}, "dependsOn": {},
"preset": {}, "dependsOn": {}, "trigger": {},
"form": {}, "maxItems": {}, "minItems": {}, "titleFrom": {},
"groups": {}, "style": {},
"currency": {}, "currencyFrom": {}, "scale": {}, "max": {},
@@ -230,6 +230,9 @@ func compileForm(pluginID string, ctl pact.AdminController, fsys fs.FS, types *f
if err := requireDependentProvider(ctl, fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
if err := checkTriggers(fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
schema := &FormSchema{
Name: doc.Name,
ModelClass: doc.ModelClass,
@@ -334,6 +337,10 @@ func localizeField(ctx context.Context, tr *phrasebook.Translator, src FormField
preset := *src.Preset
field.Preset = &preset
}
if src.Trigger != nil {
trigger := *src.Trigger
field.Trigger = &trigger
}
field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...)
@@ -646,6 +653,9 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
if err := compileDependsOn(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compileTrigger(values, &field); err != nil {
return FormField{}, err
}
if err := compileRepeater(name, typ, values, &field); err != nil {
return FormField{}, err
}

View File

@@ -279,6 +279,11 @@ 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"`
// Trigger disables or hides the field in the admin while a switch or
// checkbox of the same form is checked or unchecked (fields.yaml
// trigger). A disabled or hidden field is left out of the save body; the
// server keeps its stored value and never relies on the trigger.
Trigger *FieldTrigger `json:"trigger,omitempty"`
// Form holds the nested fields of a `type: repeater` field, one set per
// row (D-07).
Form *RepeaterForm `json:"form,omitempty"`

View File

@@ -108,6 +108,8 @@ form:
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.
trigger_disabled_on: "Not editable while “:field” is on."
trigger_disabled_off: "Not editable while “:field” is off."
dependent_waiting: "Choose “:field” first"
dependent_not_used: Not used for this choice
dependent_empty: "No “:type” records yet. Add one first, or choose another type."

View File

@@ -118,6 +118,8 @@ form:
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.
trigger_disabled_on: "Nie można edytować, gdy „:field” jest włączone."
trigger_disabled_off: "Nie można edytować, gdy „:field” jest wyłączone."
dependent_waiting: "Najpierw wybierz pole „:field”"
dependent_not_used: Nie dotyczy tego wyboru
dependent_empty: "Brak rekordów typu „:type”. Najpierw dodaj rekord albo wybierz inny typ."