diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index b84d472c..89d4f8ad 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -1194,6 +1194,25 @@ ], "type": "object" }, + "cabana.FieldTrigger": { + "properties": { + "action": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "required": [ + "action", + "condition", + "field" + ], + "type": "object" + }, "cabana.FieldTypeAssets": { "properties": { "element": { @@ -1551,6 +1570,14 @@ "description": "Translatable is true when a plugin field type's value is a map of\ncontent locale to string.", "type": "boolean" }, + "trigger": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.FieldTrigger" + } + ], + "description": "Trigger disables or hides the field in the admin while a switch or\ncheckbox of the same form is checked or unchecked (fields.yaml\ntrigger). A disabled or hidden field is left out of the save body; the\nserver keeps its stored value and never relies on the trigger." + }, "twelveHour": { "description": "TwelveHour shows a 12-hour clock.", "type": "boolean" @@ -5287,7 +5314,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. 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`.", + "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 `\u003cfield\u003e.\u003cindex\u003e.\u003cnested\u003e`.", "parameters": [ { "description": "Vendor", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index 8d114b10..234a36fe 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -2200,7 +2200,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. 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 `..`. + * @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 `..`. */ get: { parameters: { @@ -5704,6 +5704,11 @@ export interface components { field: string; type: string; }; + "cabana.FieldTrigger": { + action: string; + condition: string; + field: string; + }; "cabana.FieldTypeAssets": { element: string; name: string; @@ -5889,6 +5894,13 @@ export interface components { * content locale to string. */ translatable?: boolean; + /** + * @description 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?: components["schemas"]["cabana.FieldTrigger"]; /** @description TwelveHour shows a 12-hour clock. */ twelveHour?: boolean; type: string; diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index c9cc0be6..1e1d492a 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -40,6 +40,8 @@ export type FormRedirects = Schemas['cabana.FormRedirects'] /** A form's preview screen: present when config_form.yaml declares `preview:`. */ export type FormPreview = Schemas['cabana.FormPreview'] export type FieldPreset = Schemas['cabana.FieldPreset'] +/** A field's fields.yaml trigger: disable or hide it while a switch or checkbox is checked or unchecked. */ +export type FieldTrigger = Schemas['cabana.FieldTrigger'] export type PermissionOption = Schemas['cabana.PermissionOption'] /** A permission a stored record fixes (meta.permissionLocks): the value and note to show. */ export type PermissionRecordLock = Schemas['cabana.PermissionRecordLock'] diff --git a/admin/src/components/form/FormField.vue b/admin/src/components/form/FormField.vue index b8fa5def..38917bb3 100644 --- a/admin/src/components/form/FormField.vue +++ b/admin/src/components/form/FormField.vue @@ -1,14 +1,17 @@ + diff --git a/modules/cabana/README.md b/modules/cabana/README.md index ef818717..01c2d97e 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -38,6 +38,7 @@ Use cases: - Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: ` 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). | diff --git a/modules/cabana/admin_openapi.go b/modules/cabana/admin_openapi.go index a7d00069..2b8edd08 100644 --- a/modules/cabana/admin_openapi.go +++ b/modules/cabana/admin_openapi.go @@ -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 `..`. +// @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 `..`. // @Tags admin // @Produce json // @Security BackendBearer diff --git a/modules/cabana/backend_screens_smoke_test.go b/modules/cabana/backend_screens_smoke_test.go index 7eb9f47a..82a86a42 100644 --- a/modules/cabana/backend_screens_smoke_test.go +++ b/modules/cabana/backend_screens_smoke_test.go @@ -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") diff --git a/modules/cabana/backendscreens/models/user/fields.yaml b/modules/cabana/backendscreens/models/user/fields.yaml index a023c379..b73a08ca 100644 --- a/modules/cabana/backendscreens/models/user/fields.yaml +++ b/modules/cabana/backendscreens/models/user/fields.yaml @@ -59,3 +59,7 @@ fields: mode: radio span: full tab: backend::lang.user.permissions + trigger: + action: disable + field: is_superuser + condition: checked diff --git a/modules/cabana/field_dependent.go b/modules/cabana/field_dependent.go index aaebb45b..dfd4a938 100644 --- a/modules/cabana/field_dependent.go +++ b/modules/cabana/field_dependent.go @@ -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 } diff --git a/modules/cabana/field_repeater.go b/modules/cabana/field_repeater.go index d4fa68f1..4a565b3b 100644 --- a/modules/cabana/field_repeater.go +++ b/modules/cabana/field_repeater.go @@ -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 diff --git a/modules/cabana/field_trigger.go b/modules/cabana/field_trigger.go new file mode 100644 index 00000000..fc36b8c3 --- /dev/null +++ b/modules/cabana/field_trigger.go @@ -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 +} diff --git a/modules/cabana/field_trigger_smoke_test.go b/modules/cabana/field_trigger_smoke_test.go new file mode 100644 index 00000000..9858372a --- /dev/null +++ b/modules/cabana/field_trigger_smoke_test.go @@ -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") + }) +} diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index 0d0da1fc..4e5cce8b 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -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 } diff --git a/modules/cabana/schema_types.go b/modules/cabana/schema_types.go index f8ba9861..d3d0560a 100644 --- a/modules/cabana/schema_types.go +++ b/modules/cabana/schema_types.go @@ -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"` diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 391a98eb..5028a776 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -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." diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index eec1b090..bf2df158 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -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."