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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user