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:
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]> = {}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user