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

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