feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin

- MLPluginField wraps PluginField in the MLField locale switcher; the
  element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
  state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
  route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
  (text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
  modules/boardwalk/dist rebuilt
This commit is contained in:
2026-10-10 11:25:26 +02:00
parent 8fa92db96d
commit 4adb48a76e
21 changed files with 415 additions and 54 deletions

View File

@@ -6,11 +6,15 @@ import FieldRenderer from './FieldRenderer.vue'
import { groupLabelled, isPluginField, ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment and the error line linked by aria-describedby.
// 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.
const props = defineProps<{
field: FormField
modelValue: unknown
errors?: string[]
/** Save notices of this field: warning status lines under the control. */
notices?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
recordId?: number | null
@@ -23,7 +27,9 @@ const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
const controlId = computed(() => base.value)
const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const noticeId = computed(() => `${base.value}-notice`)
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const noticed = computed(() => (props.notices?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
// A widget, partial or plugin field type row labels a role="group" host, so
// its label is a span the host names with aria-labelledby, not a label for
@@ -32,7 +38,11 @@ const labelsGroup = computed(() => groupLabelled(props.field.type) || isPluginFi
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
[
showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '',
invalid.value ? errorId.value : '',
noticed.value ? noticeId.value : '',
]
.filter(Boolean)
.join(' '),
)
@@ -58,6 +68,9 @@ const describedBy = computed(() =>
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
<div v-if="noticed" :id="noticeId" role="status" data-field-notice class="summer-callout summer-callout--warning text-[13px]!">
<p v-for="(text, index) in notices" :key="index" class="summer-callout__text">{{ text }}</p>
</div>
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />

View File

@@ -10,6 +10,8 @@ defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record<string, string[]>
/** Save notices per field (meta.notices as noticeLines); none when absent. */
notices?: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
recordId?: number | null
@@ -45,6 +47,7 @@ function spanClass(field: FormFieldSchema): string {
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:notices="notices?.[field.name]"
:labels="labels?.[field.name]"
:source="source"
:record-id="recordId"

View File

@@ -2,8 +2,9 @@
// The one locale-switching wrapper of the ML controls (mltext, mltextarea,
// mlmarkdown). It owns the locale select, follows ML_LOCALE_CHANGE from every
// other ML control, edits only the active locale and emits the whole map. The
// inner control (TextField, TextareaField, MarkdownField) is passed in and
// edits a plain string.
// inner control (TextField, TextareaField, MarkdownField, or PluginField for a
// translatable plugin field type) is passed in and edits a plain string; with
// contentLocale set it also receives the active locale as content-locale.
import { computed, inject, onMounted, onUnmounted, ref, type Component } from 'vue'
import type { FieldControlProps } from '../control'
import { FORM_ENABLED_LOCALES } from '../formContext'
@@ -16,6 +17,8 @@ interface MLFieldProps extends FieldControlProps {
innerType: string
/** The ML field type, shown on the root as data-ml-field. */
mlType: string
/** Hand the inner control the active locale (PluginField's content-locale). */
contentLocale?: boolean
}
const props = defineProps<MLFieldProps>()
@@ -34,6 +37,9 @@ const editorId = computed(() => `${props.controlId}-editor`)
const activeText = computed(() => values.value[activeLocale.value] ?? '')
// A stable field object for the inner control: it keeps size and attributes.
const innerField = computed(() => ({ ...props.field, type: props.innerType }))
// Only an inner control that declares content-locale receives it; a text
// input would show it as an attribute.
const innerExtras = computed(() => (props.contentLocale ? { contentLocale: activeLocale.value } : {}))
function setLocale(locale: string) {
if (!locale) {
@@ -87,6 +93,9 @@ onUnmounted(() => {
:control-id="editorId"
:invalid="invalid"
:described-by="describedBy"
:source="source"
:record-id="recordId"
v-bind="innerExtras"
@update:model-value="onText"
/>
</div>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
// A translatable plugin field type (D-14): MLField around PluginField. The
// element edits the active content locale's string and receives that locale
// in its locale attribute; the form value is the locale -> string map.
import type { FieldControlProps } from '../control'
import MLField from './MLField.vue'
import PluginField from './PluginField.vue'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
</script>
<template>
<MLField
v-bind="props"
:inner="PluginField"
:inner-type="field.type"
:ml-type="field.type"
content-locale
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../../../api/client'
import { errorMessage } from '../../../api/files'
import { fieldTypeScripts } from '../../../app/fieldTypes'
import { currentLocale, t } from '../../../app/i18n'
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
import type { FieldControlProps } from '../control'
import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../formContext'
import { FIELD_CHANGE_EVENT, FIELD_UPLOAD_EVENT, FIELD_UPLOAD_RESULT_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../formContext'
// A field whose type a plugin registers (pact.HasFormFieldTypes, D-13). The
// type's custom element is created imperatively inside a node Vue never
@@ -14,8 +16,18 @@ import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../formContext'
// function or a Vue instance. The element reports an edit with a bubbling
// summer-change event whose detail.value becomes the form value. The SPA
// sets `value` only when the form value differs from the element's current
// value, so the value the element just emitted never resets it.
const props = defineProps<FieldControlProps>()
// value, so the value the element just emitted never resets it. A
// translatable type renders inside MLField (MLPluginField), which hands the
// active content locale as contentLocale. The element asks for an upload with
// a bubbling summer-upload event {id, file}; the SPA posts the file with the
// admin session to the field upload route and answers on the element with
// summer-upload-result {id, url} or {id, error}.
interface PluginFieldProps extends FieldControlProps {
/** The content locale a translatable type's value belongs to (MLField). */
contentLocale?: string
}
const props = defineProps<PluginFieldProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const formLocale = inject(FORM_LOCALE, null)
@@ -64,7 +76,7 @@ function toggle(target: HTMLElement, name: string, on: boolean): void {
function applyAttributes(target: HTMLElement): void {
target.setAttribute('field-name', props.field.name)
target.setAttribute('record-id', props.recordId == null ? '' : String(props.recordId))
target.setAttribute('locale', '')
target.setAttribute('locale', props.contentLocale ?? '')
target.setAttribute('ui-locale', uiLocale.value)
target.setAttribute('options', optionsJson.value)
toggle(target, 'disabled', disabled.value)
@@ -95,6 +107,53 @@ async function defined(tag: string): Promise<void> {
}
}
/** Posts an element's file to the field upload route and answers it. */
async function onUpload(event: Event): Promise<void> {
const target = element
const detail: unknown = (event as CustomEvent<unknown>).detail
if (!target || detail === null || typeof detail !== 'object') {
return
}
const { id, file } = detail as { id?: unknown; file?: unknown }
const reply = (answer: { url: string } | { error: string }) => {
if (!unmounted && element === target) {
target.dispatchEvent(new CustomEvent(FIELD_UPLOAD_RESULT_EVENT, { detail: { id, ...answer } }))
}
}
const failed = () => t('backend::lang.fileupload.upload_failed')
const source = props.source
if (!(file instanceof Blob) || !source) {
reply({ error: failed() })
return
}
const name = file instanceof File && file.name !== '' ? file.name : 'upload'
try {
// The typed client sends X-Requested-With and the session cookie and
// keeps the 401 refresh-and-replay; FormData sets the multipart type.
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload', {
params: { path: { ...source, id: props.recordId ?? 0, field: props.field.name } },
body: { file: '' },
bodySerializer: () => {
const form = new FormData()
form.append('file', file, name)
return form
},
})
if (result.data) {
reply({ url: result.data.data.url })
return
}
const message = result.response.status === 422 ? errorMessage(result.error?.error) : ''
reply({ error: message || failed() })
} catch {
reply({ error: failed() })
}
}
function uploadListener(event: Event): void {
void onUpload(event)
}
function onChange(event: Event): void {
const detail: unknown = (event as CustomEvent<unknown>).detail
if (detail !== null && typeof detail === 'object' && Object.hasOwn(detail, 'value')) {
@@ -116,6 +175,7 @@ onMounted(async () => {
applyAttributes(created)
syncValue(created, props.modelValue)
created.addEventListener(FIELD_CHANGE_EVENT, onChange)
created.addEventListener(FIELD_UPLOAD_EVENT, uploadListener)
mountPoint.value.append(created)
element = created
status.value = 'ready'
@@ -126,6 +186,16 @@ onMounted(async () => {
}
})
// The attributes first: on a locale switch the element sees its new locale
// before the value of that locale.
watch(
() => [props.recordId, props.invalid, props.contentLocale, disabled.value, uiLocale.value, optionsJson.value],
() => {
if (element) {
applyAttributes(element)
}
},
)
watch(
() => props.modelValue,
(value) => {
@@ -135,18 +205,11 @@ watch(
},
{ deep: true },
)
watch(
() => [props.recordId, props.invalid, disabled.value, uiLocale.value, optionsJson.value],
() => {
if (element) {
applyAttributes(element)
}
},
)
onBeforeUnmount(() => {
unmounted = true
element?.removeEventListener(FIELD_CHANGE_EVENT, onChange)
element?.removeEventListener(FIELD_UPLOAD_EVENT, uploadListener)
element = null
})
</script>

View File

@@ -98,5 +98,18 @@ export const WIDGET_RESULT_EVENT = 'summer-result'
*/
export const FIELD_CHANGE_EVENT = 'summer-change'
/**
* Event a plugin field type's element dispatches (bubbling) to upload a file,
* with detail {id, file}: the element's own request id and the File. The SPA
* posts it with the admin session to the field upload route.
*/
export const FIELD_UPLOAD_EVENT = 'summer-upload'
/**
* Event the SPA dispatches on the element when an upload ends, with detail
* {id, url} on success or {id, error} with a message on failure.
*/
export const FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'
/** How long a widget waits for its custom element to be defined. */
export const WIDGET_TIMEOUT = 5000

View File

@@ -1,11 +1,11 @@
// Helpers shared by the record form and the settings form: context
// filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import type { AdminRecord, ErrorBody, FieldNotice, FormField } from '../../api/types'
import { permissionValues } from './control'
import { isMLFieldType, localeRecord } from './mlLocale'
import { isMLField, localeRecord } from './mlLocale'
import { isRegisteredField } from './registry'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, localeRecord } from './mlLocale'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLField, isMLFieldType, localeRecord } from './mlLocale'
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
export type FormMode = 'create' | 'update' | 'preview'
@@ -65,7 +65,7 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
out[field.name] = permissionValues(field, value)
continue
}
if (isMLFieldType(field.type)) {
if (isMLField(field)) {
out[field.name] = localeRecord(value)
continue
}
@@ -136,7 +136,7 @@ export function presetUpdates(
isEdited: PresetEdited,
): AdminRecord {
const out: AdminRecord = {}
const sourceML = isMLFieldType(fields.find((field) => field.name === source)?.type)
const sourceML = isMLField(fields.find((field) => field.name === source))
for (const field of fields) {
const preset = field.preset
if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
@@ -184,7 +184,7 @@ export function initialValues(fields: FormField[], enabledLocales: readonly stri
out[field.name] = false
} else if (field.type === 'relation' && field.multiple) {
out[field.name] = []
} else if (isMLFieldType(field.type)) {
} else if (isMLField(field)) {
const seed: Record<string, string> = {}
for (const code of enabledLocales) {
seed[code] = ''
@@ -225,12 +225,12 @@ export function recordDisplayName(
values: AdminRecord,
enabledLocales: readonly string[],
): string {
const first = fields?.find((field) => field.type === 'text' || isMLFieldType(field.type))
const first = fields?.find((field) => field.type === 'text' || isMLField(field))
const value = first ? values[first.name] : undefined
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim()
}
if (!first || !isMLFieldType(first.type)) {
if (!first || !isMLField(first)) {
return ''
}
const texts = localeRecord(value)
@@ -246,6 +246,23 @@ export function recordDisplayName(
return ''
}
/**
* A save's meta.notices (D-10, 16.2) as field name -> lines shown under the
* field: a notice about one content locale reads "<LOCALE>: message". Absent
* or empty notices give an empty map.
*/
export function noticeLines(notices: readonly FieldNotice[] | null | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const notice of notices ?? []) {
if (!notice || typeof notice.message !== 'string' || notice.message === '' || typeof notice.field !== 'string') {
continue
}
const line = notice.locale ? `${notice.locale.toUpperCase()}: ${notice.message}` : notice.message
;(out[notice.field] ??= []).push(line)
}
return out
}
/** D-10 422 details (field -> messages) as a string-list map. */
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}

View File

@@ -1,3 +1,5 @@
import type { FormField } from '../../api/types'
/** The multilingual field types: their value is a locale -> text map. */
export const ML_FIELD_TYPES: ReadonlySet<string> = new Set(['mltext', 'mltextarea', 'mlmarkdown'])
@@ -6,6 +8,17 @@ export function isMLFieldType(type: string | undefined): boolean {
return type !== undefined && ML_FIELD_TYPES.has(type)
}
/**
* Reports whether a field's value is a locale -> text map: a built-in ML type
* or a translatable plugin field type (an `element` with `translatable`).
*/
export function isMLField(field: Pick<FormField, 'type' | 'element' | 'translatable'> | undefined): boolean {
if (!field) {
return false
}
return isMLFieldType(field.type) || (!!field.element && field.translatable === true)
}
/** Window event that keeps every ML control on one active locale. */
export const ML_LOCALE_CHANGE = 'summer:ml-locale'

View File

@@ -18,8 +18,9 @@
// locale-switching wrapper, MLField, around the text, textarea and markdown
// controls; each holds a locale -> text map, labelled as a group.
// Phase 16.2 adds plugin field types (D-13): a field whose schema carries an
// `element` renders PluginField whatever its type alias, holds a form value
// and is labelled as a group. Look such fields up with rendererForField and
// `element` renders PluginField whatever its type alias (MLPluginField, the
// MLField locale switcher around it, when the type is translatable), holds a
// form value and is labelled as a group. Look such fields up with rendererForField and
// isRegisteredField, which know the field, not only its type.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
@@ -30,6 +31,7 @@ import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue'
import MLPluginField from './fields/MLPluginField.vue'
import PluginField from './fields/PluginField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
@@ -115,7 +117,7 @@ export function isPluginField(field: Pick<FormField, 'element'>): boolean {
/** The control of one field: a plugin field type's host, else rendererFor its type. */
export function rendererForField(field: FormField): Component {
if (isPluginField(field)) {
return PluginField
return field.translatable ? MLPluginField : PluginField
}
return rendererFor(field.type)
}

View File

@@ -25,7 +25,7 @@ import {
fieldErrors,
focusField,
initialValues,
isMLFieldType,
isMLField,
mergeMLValue,
panelDomId,
snapshot,
@@ -218,7 +218,7 @@ function adopt(record: AdminRecord, meta?: RecordMeta): void {
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (isMLFieldType(field.type)) {
} else if (isMLField(field)) {
next[field.name] = mergeMLValue(record[field.name], locales)
}
}