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

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