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:
14
admin/src/api/schema.d.ts
vendored
14
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
64
admin/src/components/form/trigger.ts
Normal file
64
admin/src/components/form/trigger.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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