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

View File

@@ -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",

View File

@@ -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 `<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>`.
*/
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;

View File

@@ -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']

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { computed, inject } from 'vue'
import { CircleAlert, Info } from '@lucide/vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import FieldRenderer from './FieldRenderer.vue'
import { FIELD_SCOPE, FORM_TRIGGER_NOTES } from './formContext'
import { groupLabelled, isPluginField, ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment, the error line and the save notices (what a plugin
// field type changed, D-10) linked by aria-describedby. Notices are text
// interpolation only: a notice may quote markup an administrator submitted.
// A field a trigger disables (D-21) shows the trigger note between the label
// and the control, so it stays visible above a long control.
const props = defineProps<{
field: FormField
modelValue: unknown
@@ -28,6 +31,12 @@ const controlId = computed(() => base.value)
const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const noticeId = computed(() => `${base.value}-notice`)
const triggerId = computed(() => `${base.value}-trigger`)
// Notes are keyed by top-level field name: a repeater row's field (path
// `<repeater>.<field>`) never matches.
const scope = inject(FIELD_SCOPE, null)
const triggerNotes = inject(FORM_TRIGGER_NOTES, null)
const triggerNote = computed(() => triggerNotes?.value[scope ? scope.path(props.field.name) : props.field.name] ?? '')
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const noticed = computed(() => (props.notices?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
@@ -39,6 +48,7 @@ const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
[
triggerNote.value !== '' ? triggerId.value : '',
showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '',
invalid.value ? errorId.value : '',
noticed.value ? noticeId.value : '',
@@ -56,6 +66,10 @@ const describedBy = computed(() =>
<label v-else-if="!selfLabelled && !labelsGroup" :id="`${controlId}-label`" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<p v-if="triggerNote !== ''" :id="triggerId" data-trigger-note class="flex items-center gap-2 text-[13px] text-muted">
<Info :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ triggerNote }}</span>
</p>
<FieldRenderer
:field="field"
:model-value="modelValue"

View File

@@ -99,6 +99,13 @@ export const SETTINGS_SECRETS: InjectionKey<Readonly<Ref<Record<string, string>>
export const FORM_PERMISSION_LOCKS: InjectionKey<Readonly<Ref<Record<string, PermissionRecordLock[]>>>> =
Symbol('summer.form.permissionLocks')
/**
* The notes of the fields a trigger disables now (fields.yaml `trigger`,
* D-21): top-level field name to the note shown between the label and the
* control. FormView provides it; without a provider no note is shown.
*/
export const FORM_TRIGGER_NOTES: InjectionKey<Readonly<Ref<Record<string, string>>>> = Symbol('summer.form.triggerNotes')
/** Event a widget element dispatches to run its action (bubbles, composed). */
export const WIDGET_EVENT = 'summer-action'

View File

@@ -0,0 +1,64 @@
// Field triggers (fields.yaml `trigger`, D-21, UI-SPEC C6): a field is
// disabled or hidden while a switch or checkbox of the same form is checked
// or unchecked. Pure helpers: FormView and PreviewView call them with the
// whole form's values on every change. The server never relies on a trigger;
// a disabled or hidden field is simply not sent, so the stored value stays.
import type { AdminRecord, FormField } from '../../api/types'
import { t } from '../../app/i18n'
import { toggleOn } from './control'
/** Whether a field's trigger condition holds now. */
export type TriggerState = 'active' | 'inactive'
/**
* The state of a field's trigger: `checked` is active while the controlling
* value is on (toggleOn), `unchecked` while it is off. A controlling field
* missing from `present` (the schema fields this request and context show)
* counts as off. A field without a trigger is always inactive.
*/
export function triggerState(field: FormField, values: AdminRecord, present: ReadonlySet<string>): TriggerState {
const trigger = field.trigger
if (!trigger) {
return 'inactive'
}
const on = present.has(trigger.field) && toggleOn(values[trigger.field])
const active = trigger.condition === 'checked' ? on : !on
return active ? 'active' : 'inactive'
}
/** The fields a form renders and sends after its triggers, with the notes of disabled fields. */
export interface TriggeredFields {
/** Hide-active fields removed; disable-active fields as read-only copies. */
fields: FormField[]
/** Field name to the note a disabled field shows above its control. */
notes: Record<string, string>
}
/**
* Applies every trigger of `fields` against the whole form's values. A
* hide-active field is left out; a disable-active field becomes a copy with
* readOnly set and gets a note naming the controlling field by `labelOf`.
* The inputs are not changed.
*/
export function applyTriggers(
fields: readonly FormField[],
values: AdminRecord,
labelOf: (name: string) => string,
): TriggeredFields {
const present = new Set(fields.map((field) => field.name))
const out: FormField[] = []
const notes: Record<string, string> = {}
for (const field of fields) {
if (!field.trigger || triggerState(field, values, present) === 'inactive') {
out.push(field)
continue
}
if (field.trigger.action === 'hide') {
continue
}
out.push({ ...field, readOnly: true })
const key = field.trigger.condition === 'checked' ? 'backend::lang.form.trigger_disabled_on' : 'backend::lang.form.trigger_disabled_off'
notes[field.name] = t(key, { field: labelOf(field.trigger.field) })
}
return { fields: out, notes }
}

View File

@@ -17,7 +17,15 @@ import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/s
import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue'
import FormTabs from '../form/FormTabs.vue'
import { FORM_ENABLED_LOCALES, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
import {
FORM_ENABLED_LOCALES,
FORM_PATCH,
FORM_SESSION,
FORM_TRIGGER_NOTES,
FORM_VALUES,
createUploadGate,
type FormSession,
} from '../form/formContext'
import {
DEFAULT_TAB,
contextAllows,
@@ -202,6 +210,9 @@ provide(FORM_VALUES, computed(() => values.value))
provide(FORM_PATCH, update)
const enabledLocales = inject(FORM_ENABLED_LOCALES, computed(() => [] as readonly string[]))
provide(FORM_ENABLED_LOCALES, enabledLocales)
// A relation form has no triggers; the record form's notes must not reach
// a child field of the same name.
provide(FORM_TRIGGER_NOTES, readonly(ref<Record<string, string>>({})))
function update(name: string, value: unknown): void {
values.value = { ...values.value, [name]: value }

View File

@@ -22,11 +22,13 @@ import {
FORM_PATCH,
FORM_PERMISSION_LOCKS,
FORM_SESSION,
FORM_TRIGGER_NOTES,
FORM_VALUES,
createUploadGate,
} from '../components/form/formContext'
import type { AdminDependentOptionsResult } from '../api/types'
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import { applyTriggers } from '../components/form/trigger'
import {
DEFAULT_TAB,
ML_LOCALE_CHANGE,
@@ -118,13 +120,27 @@ const { activeUploads, beginUpload } = createUploadGate()
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
// A deferrable one (Phase 12.2, D-03) stays: it works on owner id 0 with the
// form's session key and the save commits its changes.
const fields = computed(() =>
const contextFields = computed(() =>
(schema.value?.fields ?? []).filter(
(field) =>
contextAllows(field, mode) &&
(mode === 'update' || !needsRecord(field.type) || (field.type === RELATION_MANAGER && !!field.deferrable)),
),
)
/** A schema field's label for a trigger note: its label, else its name. */
function fieldLabel(name: string): string {
const field = schema.value?.fields.find((item) => item.name === name)
return field?.label || name
}
// Field triggers (D-21, UI-SPEC C6), evaluated against the whole form's
// values: hide-active fields are gone from the tabs, the grid, the banner's
// field names and the save body; disable-active fields render read-only and
// are not sent. Their values stay in `values` and return when the trigger
// lets go.
const triggered = computed(() => applyTriggers(contextFields.value, values.value, fieldLabel))
const fields = computed(() => triggered.value.fields)
const fieldNames = computed(() => fields.value.map((field) => field.name))
const tabs = computed<TabItem[]>(() => {
@@ -216,7 +232,7 @@ function adopt(record: RecordEnvelope | undefined): void {
if (record) {
const locales = schemaEnabledLocales(schema.value?.meta)
const next: AdminRecord = { ...record.data }
for (const field of fields.value) {
for (const field of contextFields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (isMLField(field)) {
@@ -248,7 +264,7 @@ async function load(): Promise<void> {
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
}
if (schema.value && mode === 'create') {
values.value = initialValues(fields.value, schemaEnabledLocales(schema.value.meta))
values.value = initialValues(contextFields.value, schemaEnabledLocales(schema.value.meta))
}
adopt(recordResult?.data)
mlLocale.value = firstMLLocale()
@@ -292,7 +308,7 @@ function update(name: string, value: unknown): void {
const previous = values.value[name]
const next = { ...values.value, [name]: value }
edited.add(name)
if (isMLField(fields.value.find((field) => field.name === name))) {
if (isMLField(contextFields.value.find((field) => field.name === name))) {
let locales = editedLocales.get(name)
if (!locales) {
locales = new Set<string>()
@@ -303,9 +319,11 @@ function update(name: string, value: unknown): void {
}
}
if (mode === 'create') {
Object.assign(next, presetUpdates(fields.value, name, previous, value, values.value, mlLocale.value, isEdited))
Object.assign(next, presetUpdates(contextFields.value, name, previous, value, values.value, mlLocale.value, isEdited))
}
const disabledBefore = triggered.value.notes
values.value = next
announceDisabled(disabledBefore)
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
@@ -322,6 +340,7 @@ provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.v
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
provide(FORM_ASSETS, () => assetsReady)
provide(FORM_PERMISSION_LOCKS, computed(() => permissionLocks.value))
provide(FORM_TRIGGER_NOTES, computed(() => triggered.value.notes))
// The form's single polite live region (dependent dropdown clears, repeater
// moves) and the feedback nested controls read: errors and notices by row
// path, the confirm dialog (Phase 17, D-07, D-08).
@@ -338,6 +357,31 @@ function announce(text: string): void {
announcement.value = text
}, 50)
}
/**
* After an edit: announces "<field label>: <note>" for every field a trigger
* has just disabled. A field a trigger enables again is not announced.
*/
function announceDisabled(before: Record<string, string>): void {
const after = triggered.value.notes
const lines = Object.keys(after)
.filter((name) => !(name in before))
.map((name) => `${fieldLabel(name)}: ${after[name]}`)
if (lines.length > 0) {
announce(lines.join(' '))
}
}
// A tab whose every field a trigger hides disappears; when it was the active
// tab, the first remaining tab becomes active.
watch(
() => tabs.value.map((tab) => tab.key),
(keys) => {
if (keys.length > 0 && !keys.includes(activeTab.value)) {
activeTab.value = keys[0] ?? DEFAULT_TAB
}
},
)
provide(FORM_FEEDBACK, {
errors: computed(() => errors.value),
notices: computed(() => notices.value),

View File

@@ -14,6 +14,7 @@ import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
import { RELATION_MANAGER, isRegisteredField } from '../components/form/registry'
import { applyTriggers } from '../components/form/trigger'
import {
DEFAULT_TAB,
contextAllows,
@@ -71,8 +72,15 @@ const hintKey = ref(0)
/** Types that are never shown on preview (UI-SPEC S3). */
const hidden = new Set(['password', 'widget', RELATION_MANAGER])
// A field a `hide` trigger hides now (D-21, UI-SPEC C6) is left out, against
// the record's values; `disable` changes nothing here (every preview field is
// read-only) and no trigger note is shown.
const fields = computed<FormField[]>(() =>
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
applyTriggers(
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
values.value,
(name) => name,
).fields,
)
// The Edit button renders only when the form holds at least one value field

View File

@@ -110,7 +110,7 @@ The WinterCMS widgets that are not in this list (the code editor, the color pick
### Field options
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields, `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)), `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#money)), and `mode`, `separator` and `customTags` on taglist fields (see [Taglist](#taglist)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields, `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)), `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#money)), `mode`, `separator` and `customTags` on taglist fields (see [Taglist](#taglist)), and `trigger` (see [Triggers](#triggers)). WinterCMS keys outside this set, such as `readOnly` or `disabled`, are refused.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
@@ -131,6 +131,33 @@ Multilingual pairs follow the locale the administrator works in. An `mltext` tar
A `preset` on another field type (`mltextarea` and `mlmarkdown` included), an unknown type, or a source that is not a text or mltext field of the same form stops the start-up, and the key is not accepted on settings forms or relation forms. An `mltextarea` field is neither a preset target nor a preset source, like `textarea`.
### Triggers
`trigger` disables or hides a field while a switch or checkbox of the same form is on or off, WinterCMS's field trigger. The Administrators form uses it to make the permission overrides read-only while the Superuser switch is on, because a superuser holds every permission anyway:
```yaml
permissions:
label: backend::lang.user.permission_overrides
type: permissioneditor
mode: radio
trigger:
action: disable
field: is_superuser
condition: checked
```
| Key | Values |
|-----|--------|
| `action` | `disable` shows the field read-only with a note above the control ("Not editable while “Superuser” is on."); `hide` removes it from the form, and a tab whose every field is hidden disappears. |
| `field` | Another field of the same form, of type `switch` or `checkbox` (a virtual switch counts). |
| `condition` | `checked`: the trigger acts while the field is on; `unchecked`: while it is off. |
The admin evaluates the trigger against the whole form's values on every change, so the controlling field may sit on another tab. A controlling field that is not in the form for this request (removed for this administrator, or excluded by its `context`) counts as off. The preview screen applies `hide` only.
A disabled or hidden field is left out of the save body, so the server keeps its stored value; values typed before the field was disabled stay in the form and return when it is enabled. The trigger is a display rule only: the server does not evaluate it, and every rule that must hold is enforced in the save, whatever the client sends.
The mapping must hold exactly `action`, `field` and `condition`. A missing or extra key, another action or condition (WinterCMS's `value[...]` is not supported), a `field` that names no field of the form, names the field itself or names a field that is not a switch or checkbox stops the start-up with an error naming the controller, the field and the key. Triggers are evaluated on controller forms only: repeater rows, settings forms and relation forms refuse the key.
## Dependent dropdowns
A `type: dropdown` field with `dependsOn` takes its options from the value of a sibling field, WinterCMS's dependent dropdown. A menu entry's reference dropdown, for example, lists the posts when its type dropdown says "post" and the categories when it says "category":

View File

@@ -154,14 +154,14 @@ Plugins that show an administrator, such as an author box, read the subtitle wit
Settings → System → Administrators (`summercms.cabana.users`, `cabana.AdministratorsControllerID`) lists the backend administrators and opens a preview of each; it needs `backend.manage_users`. The list searches login, names and email and filters by role, superuser access, activation and last sign-in. The form has two tabs:
- **Account:** login, email, first and last name, the password with its confirmation, and the role. A password is required when an administrator is created and optional when one is edited (blank keeps the current one); it needs at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal confirmation, and is hashed at `admin.password.bcrypt_cost`. A login or email another administrator already uses, in either field and ignoring case, is refused on the field; when only a deleted administrator holds it, the message says so, so you can restore that account instead. A new administrator is created activated, like `admin:create`, and no mail is sent.
- **Permissions:** the superuser switch and the per-administrator permission overrides (inherit, allow, deny for each code in the catalog), stored in `backend_users.permissions` and applied last over the role's grants.
- **Permissions:** the superuser switch and the per-administrator permission overrides (inherit, allow, deny for each code in the catalog), stored in `backend_users.permissions` and applied last over the role's grants. While the superuser switch is on, the overrides are read-only (a field `trigger`, see [Triggers](forms.md#triggers)) and a save keeps the stored ones.
The server enforces these rules on every save, whatever the client sends:
- Only an administrator who is a superuser (signed in, or with a personal API token of full scope) sees superusers at all: for everyone else they are left out of the list, their ids answer 404, and the superuser switch, column and filter are not in the schemas. Sending `is_superuser` without being a superuser is refused with 403, even when the field is hidden.
- An administrator cannot grant more than they hold. Permissions they lack are locked in the override editor (a changed locked value is refused with 403), and roles whose grants (stored and code-declared) they do not hold themselves are locked in the role picker: assigning one is refused, while an administrator who already has such a role keeps it when other fields change.
- An administrator who is not a superuser cannot change, deactivate or delete an administrator whose grants they do not all hold themselves.
- On their own record an administrator may change login, email and names. A change of their own role, superuser flag or permission overrides is refused with 403, and so is a password: an administrator changes their own password in My account, which checks the current one.
- On their own record an administrator may change login, email and names. Their own permission overrides are shown locked, with a note. A change of their own role, superuser flag or permission overrides is refused with 403, and so is a password: an administrator changes their own password in My account, which checks the current one.
- A superuser cannot be demoted when it is the last active superuser.
Administrators are deactivated, deleted and restored, never removed:
@@ -179,7 +179,7 @@ Setting the password of another administrator ends that administrator's sessions
Settings → System → Roles (`summercms.cabana.roles`, `cabana.RolesControllerID`) lists, creates, edits and deletes the roles in `backend_user_roles`; it needs `backend.manage_roles`. A role has a name (unique), a code (filled from the name until you edit it, and unique too), a description, and on the Permissions tab the permission codes it grants, stored in the role's `permissions`.
- **System roles** are the roles with `is_system` set (the seeded developer and publisher roles) and every role whose code plugins grant permissions to through `pact.Permission` `Roles`. Their code cannot be changed and they cannot be deleted; name, description and stored grants stay editable. A role created on this screen is never a system role. The grants plugins declare in code are merged with the stored ones when an administrator signs in, so they apply whether or not they are stored.
- **System roles** are the roles with `is_system` set (the seeded developer and publisher roles) and every role whose code plugins grant permissions to through `pact.Permission` `Roles`. Their code cannot be changed and they cannot be deleted; name, description and stored grants stay editable. A role created on this screen is never a system role. The grants plugins declare in code are merged with the stored ones when an administrator signs in, so they apply whether or not they are stored. The role's editor shows them checked and locked, with the note that a plugin grants them; a save keeps them as stored.
- An administrator who is not a superuser cannot grant more than they hold: permissions they lack are locked in the role's editor, so they can neither add nor remove them on any role.
- A role that any administrator holds, deleted administrators included, cannot be deleted; the refusal names the role and how many administrators hold it. Give them another role first. A refused role in a bulk delete rolls back the whole selection.

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."