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:
@@ -58,6 +58,8 @@ export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
||||
export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
|
||||
/** Answer of a plugin field type upload: the URL of the stored file. */
|
||||
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
|
||||
/** One save notice of a plugin field type: field, content locale and message. */
|
||||
export type FieldNotice = Schemas['cabana.FieldNotice']
|
||||
/** A registered toolbar action the admin may run, with its localized label. */
|
||||
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,6 +10,28 @@ beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('save notices', () => {
|
||||
it('shows notices as a warning status line the control points at', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: formField('code'), modelValue: '', notices: ['PL: <style> was removed', 'Second'], idPrefix: 'f' },
|
||||
})
|
||||
const line = wrapper.find('#f-code-notice')
|
||||
expect(line.attributes('role')).toBe('status')
|
||||
expect(line.classes()).toEqual(expect.arrayContaining(['summer-callout', 'summer-callout--warning']))
|
||||
expect(line.findAll('p').map((p) => p.text())).toEqual(['PL: <style> was removed', 'Second'])
|
||||
// Literal text: an HTML-looking message never becomes an element.
|
||||
expect(line.find('style').exists()).toBe(false)
|
||||
expect(line.html()).toContain('<style>')
|
||||
expect(wrapper.find('#f-code').attributes('aria-describedby')).toBe('f-code-comment f-code-notice')
|
||||
})
|
||||
|
||||
it('shows nothing without notices', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('code'), modelValue: '', notices: [], idPrefix: 'f' } })
|
||||
expect(wrapper.find('[data-field-notice]').exists()).toBe(false)
|
||||
expect(wrapper.find('#f-code').attributes('aria-describedby')).toBe('f-code-comment')
|
||||
})
|
||||
})
|
||||
|
||||
describe('form field', () => {
|
||||
it('links the label to the control and hides the required asterisk from assistive tech', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })
|
||||
|
||||
@@ -6,11 +6,21 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import MLPluginField from '../../src/components/form/fields/MLPluginField.vue'
|
||||
import PluginField from '../../src/components/form/fields/PluginField.vue'
|
||||
import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../../src/components/form/formContext'
|
||||
import {
|
||||
FIELD_CHANGE_EVENT,
|
||||
FIELD_UPLOAD_EVENT,
|
||||
FIELD_UPLOAD_RESULT_EVENT,
|
||||
FORM_ENABLED_LOCALES,
|
||||
FORM_LOCALE,
|
||||
WIDGET_TIMEOUT,
|
||||
} from '../../src/components/form/formContext'
|
||||
import { editablePayload, initialValues, isMLField, recordDisplayName } from '../../src/components/form/formState'
|
||||
import { isRegisteredField, rendererForField } from '../../src/components/form/registry'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { resetState } from '../helpers'
|
||||
import { t } from '../../src/app/i18n'
|
||||
import { API, mockApi, resetState, type Route } from '../helpers'
|
||||
|
||||
/** A stub plugin element that counts writes to its value property. */
|
||||
class AcmeDemoNotes extends HTMLElement {
|
||||
@@ -40,6 +50,8 @@ function notesField(change: Partial<FormField> = {}): FormField {
|
||||
return { name: 'notes', type: 'notes', label: 'Notes', element: 'acme-demo-notes', typeOptions: { rows: 4 }, ...change }
|
||||
}
|
||||
|
||||
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'notes' }
|
||||
|
||||
function mountPlugin(props: { field?: FormField; modelValue?: unknown; recordId?: number | null; invalid?: boolean } = {}) {
|
||||
const locale = ref('pl')
|
||||
const wrapper = mount(PluginField, {
|
||||
@@ -50,6 +62,7 @@ function mountPlugin(props: { field?: FormField; modelValue?: unknown; recordId?
|
||||
describedBy: 'f-notes-comment',
|
||||
recordId: props.recordId === undefined ? 7 : props.recordId,
|
||||
invalid: props.invalid,
|
||||
source: SOURCE,
|
||||
},
|
||||
global: { provide: { [FORM_LOCALE as symbol]: locale } },
|
||||
})
|
||||
@@ -153,3 +166,122 @@ describe('PluginField', () => {
|
||||
expect(rendererForField({ name: 'x', type: 'notes' })).not.toBe(PluginField)
|
||||
})
|
||||
})
|
||||
|
||||
/** Dispatches summer-upload on the element and collects the answers. */
|
||||
function upload(el: HTMLElement, detail: unknown): unknown[] {
|
||||
const answers: unknown[] = []
|
||||
el.addEventListener(FIELD_UPLOAD_RESULT_EVENT, (event) => answers.push((event as CustomEvent<unknown>).detail))
|
||||
el.dispatchEvent(new CustomEvent(FIELD_UPLOAD_EVENT, { bubbles: true, detail }))
|
||||
return answers
|
||||
}
|
||||
|
||||
const UPLOAD = `POST ${API}/acme/demo/notes/7/fields/notes/upload`
|
||||
|
||||
describe('PluginField uploads', () => {
|
||||
async function mounted(routes: Record<string, Route>) {
|
||||
const calls = mockApi(routes)
|
||||
const { wrapper } = mountPlugin()
|
||||
await flushPromises()
|
||||
return { el: element(wrapper), calls }
|
||||
}
|
||||
|
||||
it('posts the file to the field upload route and answers with the url', async () => {
|
||||
const { el, calls } = await mounted({ [UPLOAD]: { status: 201, body: { data: { url: '/storage/a.png' }, meta: {} } } })
|
||||
const answers = upload(el, { id: 'u1', file: new File(['png'], 'a.png', { type: 'image/png' }) })
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([{ id: 'u1', url: '/storage/a.png' }])
|
||||
expect(calls).toHaveLength(1)
|
||||
const request = calls[0]!
|
||||
expect(request.method).toBe('POST')
|
||||
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
const form = await request.clone().formData()
|
||||
const file = form.get('file')
|
||||
expect(file).toBeInstanceOf(File)
|
||||
expect((file as File).name).toBe('a.png')
|
||||
expect([...form.keys()]).toEqual(['file'])
|
||||
})
|
||||
|
||||
it('answers a 422 with the server message', async () => {
|
||||
const { el } = await mounted({
|
||||
[UPLOAD]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { notes: ['Not an image.'] } } } },
|
||||
})
|
||||
const answers = upload(el, { id: 'u2', file: new File(['x'], 'a.txt') })
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([{ id: 'u2', error: 'Not an image.' }])
|
||||
})
|
||||
|
||||
it('answers a 413 and a network failure with the generic message', async () => {
|
||||
const { el } = await mounted({ [UPLOAD]: { status: 413, body: { error: { code: 'payload_too_large', message: 'Too large', details: {} } } } })
|
||||
const too = upload(el, { id: 'u3', file: new File(['x'], 'a.txt') })
|
||||
await flushPromises()
|
||||
expect(too).toEqual([{ id: 'u3', error: t('backend::lang.fileupload.upload_failed') }])
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
||||
const down = upload(el, { id: 'u4', file: new File(['x'], 'a.txt') })
|
||||
await flushPromises()
|
||||
expect(down).toEqual([{ id: 'u4', error: t('backend::lang.fileupload.upload_failed') }])
|
||||
})
|
||||
|
||||
it('answers an upload without a file without a request', async () => {
|
||||
const { el, calls } = await mounted({})
|
||||
const answers = upload(el, { id: 'u5', file: 'not a file' })
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([{ id: 'u5', error: t('backend::lang.fileupload.upload_failed') }])
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stored value reload', () => {
|
||||
it('sets the stored value once when a save changed it', async () => {
|
||||
const { wrapper } = mountPlugin({ modelValue: '<p>a</p><style>x</style>' })
|
||||
await flushPromises()
|
||||
const el = element(wrapper)
|
||||
// The save answered with what the type stored; the form adopts it.
|
||||
await wrapper.setProps({ modelValue: '<p>a</p>' })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: '<p>a</p>' })
|
||||
await flushPromises()
|
||||
expect(el.writes).toEqual(['<p>a</p><style>x</style>', '<p>a</p>'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MLPluginField', () => {
|
||||
class AcmeDemoNotesml extends AcmeDemoNotes {}
|
||||
if (!customElements.get('acme-demo-notesml')) {
|
||||
customElements.define('acme-demo-notesml', AcmeDemoNotesml)
|
||||
}
|
||||
const mlField = notesField({ name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true })
|
||||
|
||||
it('renders through the ML switcher and edits one locale', async () => {
|
||||
expect(rendererForField(mlField)).toBe(MLPluginField)
|
||||
const wrapper = mount(MLPluginField, {
|
||||
props: { field: mlField, modelValue: { pl: 'Polski', en: 'English' }, controlId: 'f-body', recordId: 7, source: SOURCE },
|
||||
global: { provide: { [FORM_ENABLED_LOCALES as symbol]: ref(['pl', 'en']), [FORM_LOCALE as symbol]: ref('pl') } },
|
||||
})
|
||||
await flushPromises()
|
||||
const el = wrapper.find('acme-demo-notesml').element as AcmeDemoNotesml
|
||||
expect(el.getAttribute('locale')).toBe('pl')
|
||||
expect(el.value).toBe('Polski')
|
||||
expect(el.getAttribute('record-id')).toBe('7')
|
||||
|
||||
await wrapper.find('select[data-ml-locale]').setValue('en')
|
||||
await flushPromises()
|
||||
expect(el.getAttribute('locale')).toBe('en')
|
||||
expect(el.value).toBe('English')
|
||||
|
||||
el.edit('English 2')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[{ pl: 'Polski', en: 'English 2' }]])
|
||||
})
|
||||
|
||||
it('is a locale map in the form state helpers', () => {
|
||||
expect(isMLField(mlField)).toBe(true)
|
||||
expect(isMLField(notesField())).toBe(false)
|
||||
expect(isMLField({ type: 'notesml', translatable: true })).toBe(false)
|
||||
expect(initialValues([mlField], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } })
|
||||
expect(editablePayload([mlField, notesField()], { body: { pl: 'P', en: 'E', xx: 3 }, notes: { blocks: [] } })).toEqual({
|
||||
body: { pl: 'P', en: 'E' },
|
||||
notes: { blocks: [] },
|
||||
})
|
||||
expect(recordDisplayName([mlField], { body: { pl: 'Tytuł', en: 'Title' } }, ['pl', 'en'])).toBe('Tytuł')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
focusField,
|
||||
initialValues,
|
||||
mergeMLValue,
|
||||
noticeLines,
|
||||
panelDomId,
|
||||
presetUpdates,
|
||||
presetValue,
|
||||
@@ -302,3 +303,24 @@ describe('recordDisplayName', () => {
|
||||
expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post')
|
||||
})
|
||||
})
|
||||
|
||||
describe('noticeLines', () => {
|
||||
it('maps notices to lines per field, prefixing the content locale', () => {
|
||||
expect(
|
||||
noticeLines([
|
||||
{ field: 'content', locale: 'pl', message: '2 elements were removed: style, iframe' },
|
||||
{ field: 'content', locale: 'en', message: 'A script was removed' },
|
||||
{ field: 'notes', message: 'Trimmed' },
|
||||
]),
|
||||
).toEqual({
|
||||
content: ['PL: 2 elements were removed: style, iframe', 'EN: A script was removed'],
|
||||
notes: ['Trimmed'],
|
||||
})
|
||||
})
|
||||
|
||||
it('is empty without notices', () => {
|
||||
expect(noticeLines(undefined)).toEqual({})
|
||||
expect(noticeLines([])).toEqual({})
|
||||
expect(noticeLines([{ field: 'x', message: '' }])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,14 @@ beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('plugin field type columns (16.2)', () => {
|
||||
it('renders the ListValue text of a plugin type as plain text', () => {
|
||||
const wrapper = cell({ ...text, type: 'notes' }, '<b>3 blocks</b>')
|
||||
expect(wrapper.text()).toBe('<b>3 blocks</b>')
|
||||
expect(wrapper.find('b').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cell value (D-12)', () => {
|
||||
it.each([
|
||||
['a string', 'Blue', 'Blue'],
|
||||
|
||||
@@ -122,11 +122,19 @@ A type that stores files (images in a document, say) sets `Upload`. The element
|
||||
|
||||
## The element
|
||||
|
||||
The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else:
|
||||
The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else. The element contract:
|
||||
|
||||
- The `value` property holds the field's value: any JSON value for a type that is not translatable.
|
||||
- The attributes `field-name`, `record-id` (empty on the create form), `locale` (empty for a type that is not translatable), `ui-locale` (the admin's interface language), `options` (the JSON of `typeOptions`), `disabled` (present when the field is read-only) and `invalid` (present while the field has an error).
|
||||
- The element dispatches a bubbling `summer-change` event with `detail.value` when the administrator edits the value, and only then; the admin takes `detail.value` as the field's new value.
|
||||
- The admin sets `value` only when the form value differs from the element's current `value`, so setting the value the element just emitted never resets it. An element must still accept a `value` it did not emit, such as the stored value after a save.
|
||||
- The `value` property holds the field's value: a string (the active locale's text) for a translatable type, any JSON value otherwise.
|
||||
- The attributes are `field-name`; `record-id`, empty on the create form; `locale`, the content locale of the value for a translatable type and empty otherwise; `ui-locale`, the admin's interface language; `options`, the JSON of the field's `typeOptions`; `disabled`, present when the field is read-only (on a preview screen, for example); and `invalid`, present while the field has an error.
|
||||
- `summer-change` is a bubbling `CustomEvent` the element dispatches when the administrator edits the value, and only then. The admin takes `detail.value` as the field's new value; for a translatable type it replaces only the active locale's string in the locale map.
|
||||
- `summer-upload` is a bubbling `CustomEvent` the element dispatches to upload a file, with `detail` `{id, file}`: an id of the element's choosing and the `File`. The admin posts the file with the administrator's session to the field's [upload route](#uploads) and answers by dispatching `summer-upload-result` on the element, with `detail` `{id, url}` on success or `{id, error}` with a message to show (the server's message for a refused file, a generic one for a file that is too large or a network failure).
|
||||
- The admin sets `value` only when the form value differs from the element's current `value` (compared by JSON for objects), so setting the value the element just emitted never resets it. The element must still accept a `value` it did not emit: the stored value after a save, which may differ from what it emitted when `Decode` changed it, and the other locale's text when the administrator switches locales (the admin sets `locale` first, then `value`).
|
||||
- An element should not re-serialize a value the administrator did not edit, so an untouched value stays equal to the one it was served and the save skips it.
|
||||
|
||||
The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own. It styles itself only through the admin's public `--c-*` CSS variables, with every selector scoped under its own tag, because its stylesheet is enabled on every admin page.
|
||||
The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own: the admin owns HTTP and the session. It styles itself only through the admin's public `--c-*` CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists, which switch with dark mode), with every selector scoped under its own tag, because its stylesheet is enabled on every admin page. A type whose script fails to load, or whose tag is not defined within five seconds, shows the admin's failure box in place of the field.
|
||||
|
||||
A translatable type renders inside the admin's locale switcher, the same one `mlmarkdown` uses: one element edits the active locale, and switching locales changes its `locale` attribute and its `value`.
|
||||
|
||||
### How the admin shows notices
|
||||
|
||||
After a save whose response carries `meta.notices`, the admin shows each message under its field as a warning status line, prefixed with the upper-case locale for a translatable type (`PL: 2 elements were removed: style, iframe`), and appends the messages to the saved toast, so "Save and close", which leaves the form, still tells the administrator. The lines go away on the next save attempt. The admin then reloads the stored values from the response into the form, and the element receives the stored value.
|
||||
|
||||
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-CuK_LdRk.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-CuK_LdRk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-By4vm3Ku.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
|
||||
<script type="module" crossorigin src="./assets/index-CuK_LdRk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DgNySjE5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user