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

@@ -24,8 +24,9 @@ import {
fieldErrors,
focusField,
initialValues,
isMLFieldType,
isMLField,
mergeMLValue,
noticeLines,
panelDomId,
presetUpdates,
recordDisplayName,
@@ -68,6 +69,9 @@ const errors = ref<Record<string, string[]>>({})
// The text of a save the server refused with 403 (UI-SPEC S6). It is a
// banner, not a toast: it stays readable until the next save attempt.
const forbidden = ref<string | null>(null)
// What plugin field types changed on the last save (meta.notices, D-10):
// lines under each field until the next save attempt.
const notices = ref<Record<string, string[]>>({})
const loading = ref(true)
const failed = ref(false)
const busy = ref(false)
@@ -177,7 +181,7 @@ function adopt(record: RecordEnvelope | undefined): 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.data[field.name], locales)
}
}
@@ -248,8 +252,7 @@ function update(name: string, value: unknown): void {
const previous = values.value[name]
const next = { ...values.value, [name]: value }
edited.add(name)
const type = fields.value.find((field) => field.name === name)?.type
if (isMLFieldType(type)) {
if (isMLField(fields.value.find((field) => field.name === name))) {
let locales = editedLocales.get(name)
if (!locales) {
locales = new Set<string>()
@@ -319,6 +322,7 @@ async function save(): Promise<RecordEnvelope | null> {
}
busy.value = true
forbidden.value = null
notices.value = {}
try {
const body = editablePayload(fields.value, values.value, mode)
const result =
@@ -330,11 +334,18 @@ async function save(): Promise<RecordEnvelope | null> {
})
if (result.data) {
errors.value = {}
// adopt reloads the stored values, so a field whose type changed the
// submitted value shows what was stored.
adopt(result.data)
notices.value = noticeLines(result.data.meta.notices)
// The save committed everything held against the session.
pendingChanges.value = 0
revision.value++
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
const savedText = message(schema.value.messages.saved, undefined, { name: recordName.value })
// The toast repeats the notices, so a save that navigates away (save
// and close) still tells the administrator what changed.
const noticeText = Object.values(notices.value).flat().join(' ')
showToast(noticeText === '' ? savedText : `${savedText} ${noticeText}`)
return result.data
}
if (result.response.status === 422) {
@@ -502,6 +513,7 @@ void load()
:fields="panelFields"
:values="values"
:errors="errors"
:notices="notices"
:labels="labels"
:source="path"
:record-id="recordId"