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

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