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:
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-BW1fufT0.js
vendored
Normal file
11
modules/boardwalk/dist/assets/index-BW1fufT0.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-Dj7oK1ZY.js
vendored
11
modules/boardwalk/dist/assets/index-Dj7oK1ZY.js
vendored
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -59,3 +59,7 @@ fields:
|
||||
mode: radio
|
||||
span: full
|
||||
tab: backend::lang.user.permissions
|
||||
trigger:
|
||||
action: disable
|
||||
field: is_superuser
|
||||
condition: checked
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
101
modules/cabana/field_trigger.go
Normal file
101
modules/cabana/field_trigger.go
Normal 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
|
||||
}
|
||||
107
modules/cabana/field_trigger_smoke_test.go
Normal file
107
modules/cabana/field_trigger_smoke_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user