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:
@@ -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>
|
||||
|
||||
22
admin/src/components/form/fields/MLPluginField.vue
Normal file
22
admin/src/components/form/fields/MLPluginField.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user