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