feat(admin): render mltextarea fields per locale
- MLField.vue is the one locale-switching wrapper; MLTextField, MLMarkdownField and the new MLTextareaField are shells around it - isMLFieldType replaces the inline ML type-name checks in formState, FormView and RelationChildModal - register mltextarea as a group-labelled control; rebuild dist
This commit is contained in:
93
admin/src/components/form/fields/MLField.vue
Normal file
93
admin/src/components/form/fields/MLField.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 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.
|
||||||
|
import { computed, inject, onMounted, onUnmounted, ref, type Component } from 'vue'
|
||||||
|
import type { FieldControlProps } from '../control'
|
||||||
|
import { FORM_ENABLED_LOCALES } from '../formContext'
|
||||||
|
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
||||||
|
|
||||||
|
interface MLFieldProps extends FieldControlProps {
|
||||||
|
/** The control that edits the active locale's text. */
|
||||||
|
inner: Component
|
||||||
|
/** The field type the inner control receives (text, textarea, markdown). */
|
||||||
|
innerType: string
|
||||||
|
/** The ML field type, shown on the root as data-ml-field. */
|
||||||
|
mlType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<MLFieldProps>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
|
||||||
|
const values = computed(() => localeRecord(props.modelValue))
|
||||||
|
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
|
||||||
|
const locales = computed(() => {
|
||||||
|
const codes = enabledLocales?.value
|
||||||
|
return codes && codes.length > 0 ? [...codes] : []
|
||||||
|
})
|
||||||
|
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||||
|
|
||||||
|
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||||
|
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 }))
|
||||||
|
|
||||||
|
function setLocale(locale: string) {
|
||||||
|
if (!locale) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeLocale.value = locale
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocaleInput(event: Event) {
|
||||||
|
const locale = (event.target as HTMLSelectElement).value
|
||||||
|
setLocale(locale)
|
||||||
|
broadcastMLLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onText(text: string) {
|
||||||
|
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBroadcast(event: Event) {
|
||||||
|
const locale = (event as CustomEvent<string>).detail
|
||||||
|
if (typeof locale === 'string' && locale !== '') {
|
||||||
|
setLocale(locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" :data-ml-field="mlType">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
data-ml-locale
|
||||||
|
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||||
|
:value="activeLocale"
|
||||||
|
:aria-label="`Locale for ${field.label || field.name}`"
|
||||||
|
@change="onLocaleInput"
|
||||||
|
>
|
||||||
|
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<component
|
||||||
|
:is="inner"
|
||||||
|
:field="innerField"
|
||||||
|
:model-value="activeText"
|
||||||
|
:control-id="editorId"
|
||||||
|
:invalid="invalid"
|
||||||
|
:described-by="describedBy"
|
||||||
|
@update:model-value="onText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,78 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
// The mlmarkdown control: MLField around MarkdownField.
|
||||||
import type { FieldControlProps } from '../control'
|
import type { FieldControlProps } from '../control'
|
||||||
|
import MLField from './MLField.vue'
|
||||||
import MarkdownField from './MarkdownField.vue'
|
import MarkdownField from './MarkdownField.vue'
|
||||||
import { FORM_ENABLED_LOCALES } from '../formContext'
|
|
||||||
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
|
||||||
|
|
||||||
const props = defineProps<FieldControlProps>()
|
const props = defineProps<FieldControlProps>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
|
||||||
const values = computed(() => localeRecord(props.modelValue))
|
|
||||||
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
|
|
||||||
const locales = computed(() => {
|
|
||||||
const codes = enabledLocales?.value
|
|
||||||
return codes && codes.length > 0 ? [...codes] : []
|
|
||||||
})
|
|
||||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
|
||||||
|
|
||||||
const labelledBy = computed(() => `${props.controlId}-label`)
|
|
||||||
const editorId = computed(() => `${props.controlId}-editor`)
|
|
||||||
const activeText = computed(() => values.value[activeLocale.value] ?? '')
|
|
||||||
const markdownField = computed(() => ({ ...props.field, type: 'markdown' }))
|
|
||||||
|
|
||||||
function setLocale(locale: string) {
|
|
||||||
if (!locale) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeLocale.value = locale
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLocaleInput(event: Event) {
|
|
||||||
const locale = (event.target as HTMLSelectElement).value
|
|
||||||
setLocale(locale)
|
|
||||||
broadcastMLLocale(locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onText(text: string) {
|
|
||||||
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBroadcast(event: Event) {
|
|
||||||
const locale = (event as CustomEvent<string>).detail
|
|
||||||
if (typeof locale === 'string' && locale !== '') {
|
|
||||||
setLocale(locale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" data-ml-field="mlmarkdown">
|
<MLField
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
v-bind="props"
|
||||||
<select
|
:inner="MarkdownField"
|
||||||
data-ml-locale
|
inner-type="markdown"
|
||||||
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
ml-type="mlmarkdown"
|
||||||
:value="activeLocale"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
:aria-label="`Locale for ${field.label || field.name}`"
|
/>
|
||||||
@change="onLocaleInput"
|
|
||||||
>
|
|
||||||
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<MarkdownField
|
|
||||||
:field="markdownField"
|
|
||||||
:model-value="activeText"
|
|
||||||
:control-id="editorId"
|
|
||||||
:invalid="invalid"
|
|
||||||
:described-by="describedBy"
|
|
||||||
@update:model-value="onText"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,77 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
// The mltext control: MLField around TextField.
|
||||||
import type { FieldControlProps } from '../control'
|
import type { FieldControlProps } from '../control'
|
||||||
|
import MLField from './MLField.vue'
|
||||||
import TextField from './TextField.vue'
|
import TextField from './TextField.vue'
|
||||||
import { FORM_ENABLED_LOCALES } from '../formContext'
|
|
||||||
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
|
||||||
|
|
||||||
const props = defineProps<FieldControlProps>()
|
const props = defineProps<FieldControlProps>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
|
||||||
const values = computed(() => localeRecord(props.modelValue))
|
|
||||||
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
|
|
||||||
const locales = computed(() => {
|
|
||||||
const codes = enabledLocales?.value
|
|
||||||
return codes && codes.length > 0 ? [...codes] : []
|
|
||||||
})
|
|
||||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
|
||||||
|
|
||||||
const labelledBy = computed(() => `${props.controlId}-label`)
|
|
||||||
const editorId = computed(() => `${props.controlId}-editor`)
|
|
||||||
const activeText = computed(() => values.value[activeLocale.value] ?? '')
|
|
||||||
|
|
||||||
function setLocale(locale: string) {
|
|
||||||
if (!locale) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeLocale.value = locale
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLocaleInput(event: Event) {
|
|
||||||
const locale = (event.target as HTMLSelectElement).value
|
|
||||||
setLocale(locale)
|
|
||||||
broadcastMLLocale(locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onText(text: string) {
|
|
||||||
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBroadcast(event: Event) {
|
|
||||||
const locale = (event as CustomEvent<string>).detail
|
|
||||||
if (typeof locale === 'string' && locale !== '') {
|
|
||||||
setLocale(locale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" data-ml-field="mltext">
|
<MLField
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
v-bind="props"
|
||||||
<select
|
:inner="TextField"
|
||||||
data-ml-locale
|
inner-type="text"
|
||||||
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
ml-type="mltext"
|
||||||
:value="activeLocale"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
:aria-label="`Locale for ${field.label || field.name}`"
|
/>
|
||||||
@change="onLocaleInput"
|
|
||||||
>
|
|
||||||
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
:field="{ ...field, type: 'text' }"
|
|
||||||
:model-value="activeText"
|
|
||||||
:control-id="editorId"
|
|
||||||
:invalid="invalid"
|
|
||||||
:described-by="describedBy"
|
|
||||||
@update:model-value="onText"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
19
admin/src/components/form/fields/MLTextareaField.vue
Normal file
19
admin/src/components/form/fields/MLTextareaField.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// The mltextarea control: MLField around TextareaField.
|
||||||
|
import type { FieldControlProps } from '../control'
|
||||||
|
import MLField from './MLField.vue'
|
||||||
|
import TextareaField from './TextareaField.vue'
|
||||||
|
|
||||||
|
const props = defineProps<FieldControlProps>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MLField
|
||||||
|
v-bind="props"
|
||||||
|
:inner="TextareaField"
|
||||||
|
inner-type="textarea"
|
||||||
|
ml-type="mltextarea"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
// filtering, the save payload, 422 detail mapping and focus.
|
// filtering, the save payload, 422 detail mapping and focus.
|
||||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||||
import { permissionValues } from './control'
|
import { permissionValues } from './control'
|
||||||
import { localeRecord } from './mlLocale'
|
import { isMLFieldType, localeRecord } from './mlLocale'
|
||||||
import { isRegistered } from './registry'
|
import { isRegistered } from './registry'
|
||||||
|
|
||||||
export { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from './mlLocale'
|
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, localeRecord } from './mlLocale'
|
||||||
|
|
||||||
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
|
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
|
||||||
export type FormMode = 'create' | 'update' | 'preview'
|
export type FormMode = 'create' | 'update' | 'preview'
|
||||||
@@ -65,7 +65,7 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
|
|||||||
out[field.name] = permissionValues(field, value)
|
out[field.name] = permissionValues(field, value)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
if (isMLFieldType(field.type)) {
|
||||||
out[field.name] = localeRecord(value)
|
out[field.name] = localeRecord(value)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -111,10 +111,6 @@ export function changedLocales(previous: unknown, next: unknown): string[] {
|
|||||||
* whole field when no locale is given, one locale of an ML target otherwise. */
|
* whole field when no locale is given, one locale of an ML target otherwise. */
|
||||||
export type PresetEdited = (target: string, locale?: string) => boolean
|
export type PresetEdited = (target: string, locale?: string) => boolean
|
||||||
|
|
||||||
function isMLType(type: string | undefined): boolean {
|
|
||||||
return type === 'mltext' || type === 'mlmarkdown'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preset targets of `source` (fields.yaml `preset`) to rewrite after the
|
* The preset targets of `source` (fields.yaml `preset`) to rewrite after the
|
||||||
* source changed from `previous` to `next`: target name -> new value. Targets
|
* source changed from `previous` to `next`: target name -> new value. Targets
|
||||||
@@ -140,7 +136,7 @@ export function presetUpdates(
|
|||||||
isEdited: PresetEdited,
|
isEdited: PresetEdited,
|
||||||
): AdminRecord {
|
): AdminRecord {
|
||||||
const out: AdminRecord = {}
|
const out: AdminRecord = {}
|
||||||
const sourceML = isMLType(fields.find((field) => field.name === source)?.type)
|
const sourceML = isMLFieldType(fields.find((field) => field.name === source)?.type)
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const preset = field.preset
|
const preset = field.preset
|
||||||
if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
|
if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
|
||||||
@@ -188,7 +184,7 @@ export function initialValues(fields: FormField[], enabledLocales: readonly stri
|
|||||||
out[field.name] = false
|
out[field.name] = false
|
||||||
} else if (field.type === 'relation' && field.multiple) {
|
} else if (field.type === 'relation' && field.multiple) {
|
||||||
out[field.name] = []
|
out[field.name] = []
|
||||||
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
} else if (isMLFieldType(field.type)) {
|
||||||
const seed: Record<string, string> = {}
|
const seed: Record<string, string> = {}
|
||||||
for (const code of enabledLocales) {
|
for (const code of enabledLocales) {
|
||||||
seed[code] = ''
|
seed[code] = ''
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/** The multilingual field types: their value is a locale -> text map. */
|
||||||
|
export const ML_FIELD_TYPES: ReadonlySet<string> = new Set(['mltext', 'mltextarea', 'mlmarkdown'])
|
||||||
|
|
||||||
|
/** Reports whether a field type holds a locale -> text map. */
|
||||||
|
export function isMLFieldType(type: string | undefined): boolean {
|
||||||
|
return type !== undefined && ML_FIELD_TYPES.has(type)
|
||||||
|
}
|
||||||
|
|
||||||
/** Window event that keeps every ML control on one active locale. */
|
/** Window event that keeps every ML control on one active locale. */
|
||||||
export const ML_LOCALE_CHANGE = 'summer:ml-locale'
|
export const ML_LOCALE_CHANGE = 'summer:ml-locale'
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
// server never sends back, so it is empty on load and after every save. The
|
// server never sends back, so it is empty on load and after every save. The
|
||||||
// permissioneditor control is a value field too: an object of permission code
|
// permissioneditor control is a value field too: an object of permission code
|
||||||
// to value, labelled as a group.
|
// to value, labelled as a group.
|
||||||
|
// The ML controls (mltext, mltextarea, mlmarkdown) are thin shells over one
|
||||||
|
// locale-switching wrapper, MLField, around the text, textarea and markdown
|
||||||
|
// controls; each holds a locale -> text map, labelled as a group.
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import CheckboxField from './fields/CheckboxField.vue'
|
import CheckboxField from './fields/CheckboxField.vue'
|
||||||
import DatepickerField from './fields/DatepickerField.vue'
|
import DatepickerField from './fields/DatepickerField.vue'
|
||||||
@@ -30,6 +33,7 @@ import TextField from './fields/TextField.vue'
|
|||||||
import TextareaField from './fields/TextareaField.vue'
|
import TextareaField from './fields/TextareaField.vue'
|
||||||
import MarkdownField from './fields/MarkdownField.vue'
|
import MarkdownField from './fields/MarkdownField.vue'
|
||||||
import MLTextField from './fields/MLTextField.vue'
|
import MLTextField from './fields/MLTextField.vue'
|
||||||
|
import MLTextareaField from './fields/MLTextareaField.vue'
|
||||||
import MLMarkdownField from './fields/MLMarkdownField.vue'
|
import MLMarkdownField from './fields/MLMarkdownField.vue'
|
||||||
import UnsupportedField from './fields/UnsupportedField.vue'
|
import UnsupportedField from './fields/UnsupportedField.vue'
|
||||||
import WidgetField from './fields/WidgetField.vue'
|
import WidgetField from './fields/WidgetField.vue'
|
||||||
@@ -65,6 +69,7 @@ const renderers = new Map<string, Component>([
|
|||||||
['permissioneditor', PermissionEditorField],
|
['permissioneditor', PermissionEditorField],
|
||||||
['markdown', MarkdownField],
|
['markdown', MarkdownField],
|
||||||
['mltext', MLTextField],
|
['mltext', MLTextField],
|
||||||
|
['mltextarea', MLTextareaField],
|
||||||
['mlmarkdown', MLMarkdownField],
|
['mlmarkdown', MLMarkdownField],
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -85,7 +90,7 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
|||||||
* Types whose control is a group rather than one focusable element: the
|
* Types whose control is a group rather than one focusable element: the
|
||||||
* visible label is a span the group points at, not a label for an input.
|
* visible label is a span the group points at, not a label for an input.
|
||||||
*/
|
*/
|
||||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mlmarkdown'])
|
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown'])
|
||||||
|
|
||||||
export function rendererFor(type: string): Component {
|
export function rendererFor(type: string): Component {
|
||||||
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
fieldErrors,
|
fieldErrors,
|
||||||
focusField,
|
focusField,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
isMLFieldType,
|
||||||
mergeMLValue,
|
mergeMLValue,
|
||||||
panelDomId,
|
panelDomId,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -217,7 +218,7 @@ function adopt(record: AdminRecord, meta?: RecordMeta): void {
|
|||||||
for (const field of fields.value) {
|
for (const field of fields.value) {
|
||||||
if (field.type === 'password') {
|
if (field.type === 'password') {
|
||||||
next[field.name] = ''
|
next[field.name] = ''
|
||||||
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
} else if (isMLFieldType(field.type)) {
|
||||||
next[field.name] = mergeMLValue(record[field.name], locales)
|
next[field.name] = mergeMLValue(record[field.name], locales)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
fieldErrors,
|
fieldErrors,
|
||||||
focusField,
|
focusField,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
isMLFieldType,
|
||||||
mergeMLValue,
|
mergeMLValue,
|
||||||
panelDomId,
|
panelDomId,
|
||||||
presetUpdates,
|
presetUpdates,
|
||||||
@@ -177,7 +178,7 @@ function adopt(record: RecordEnvelope | undefined): void {
|
|||||||
for (const field of fields.value) {
|
for (const field of fields.value) {
|
||||||
if (field.type === 'password') {
|
if (field.type === 'password') {
|
||||||
next[field.name] = ''
|
next[field.name] = ''
|
||||||
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
} else if (isMLFieldType(field.type)) {
|
||||||
next[field.name] = mergeMLValue(record.data[field.name], locales)
|
next[field.name] = mergeMLValue(record.data[field.name], locales)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ async function load(): Promise<void> {
|
|||||||
const edited = new Set<string>()
|
const edited = new Set<string>()
|
||||||
// The locales of each ML field the administrator edited by hand.
|
// The locales of each ML field the administrator edited by hand.
|
||||||
const editedLocales = new Map<string, Set<string>>()
|
const editedLocales = new Map<string, Set<string>>()
|
||||||
// The locale every ML control shows (MLTextField broadcasts the choice).
|
// The locale every ML control shows (any ML control broadcasts the choice).
|
||||||
const mlLocale = ref('en')
|
const mlLocale = ref('en')
|
||||||
|
|
||||||
function firstMLLocale(): string {
|
function firstMLLocale(): string {
|
||||||
@@ -249,7 +250,7 @@ function update(name: string, value: unknown): void {
|
|||||||
const next = { ...values.value, [name]: value }
|
const next = { ...values.value, [name]: value }
|
||||||
edited.add(name)
|
edited.add(name)
|
||||||
const type = fields.value.find((field) => field.name === name)?.type
|
const type = fields.value.find((field) => field.name === name)?.type
|
||||||
if (type === 'mltext' || type === 'mlmarkdown') {
|
if (isMLFieldType(type)) {
|
||||||
let locales = editedLocales.get(name)
|
let locales = editedLocales.get(name)
|
||||||
if (!locales) {
|
if (!locales) {
|
||||||
locales = new Set<string>()
|
locales = new Set<string>()
|
||||||
|
|||||||
@@ -630,4 +630,27 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
|
|||||||
await localeSelect(wrapper, 'slug').setValue('pl')
|
await localeSelect(wrapper, 'slug').setValue('pl')
|
||||||
expect(editor(wrapper, 'slug').element.value).toBe('')
|
expect(editor(wrapper, 'slug').element.value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('edits an mltextarea field per locale and saves the nested map', async () => {
|
||||||
|
const schema = mlSchema('mltextarea', 'text')
|
||||||
|
for (const item of schema.body.data.fields) {
|
||||||
|
if (item.name === 'name') {
|
||||||
|
item.size = 'small'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const record = clone(rosterRecordFixture)
|
||||||
|
record.data.name = { en: 'Ada\nLovelace', pl: '' }
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/roster/people/1',
|
||||||
|
people({ [`GET ${PEOPLE}/schema/form`]: schema, [`GET ${PERSON}`]: { body: record }, [`PUT ${PERSON}`]: { body: rosterRecordFixture } }),
|
||||||
|
)
|
||||||
|
const textarea = wrapper.find<HTMLTextAreaElement>('#field-name-editor')
|
||||||
|
expect(textarea.element.tagName).toBe('TEXTAREA')
|
||||||
|
expect(textarea.element.value).toBe('Ada\nLovelace')
|
||||||
|
await localeSelect(wrapper, 'name').setValue('pl')
|
||||||
|
await wrapper.find<HTMLTextAreaElement>('#field-name-editor').setValue('Ada po polsku')
|
||||||
|
await save(wrapper)
|
||||||
|
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
|
||||||
|
expect(body.name).toEqual({ en: 'Ada\nLovelace', pl: 'Ada po polsku' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { ref } from 'vue'
|
|||||||
import type { FormField } from '../../src/api/types'
|
import type { FormField } from '../../src/api/types'
|
||||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||||
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||||
|
import MLTextareaField from '../../src/components/form/fields/MLTextareaField.vue'
|
||||||
import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
||||||
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||||
import { editablePayload, initialValues, mergeMLValue } from '../../src/components/form/formState'
|
import { editablePayload, initialValues, isMLFieldType, mergeMLValue } from '../../src/components/form/formState'
|
||||||
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||||
import { API, mockApi, resetState } from '../helpers'
|
import { API, mockApi, resetState } from '../helpers'
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ function field(type: string, name = 'title'): FormField {
|
|||||||
|
|
||||||
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
|
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
|
||||||
|
|
||||||
function mlMount(component: typeof MLTextField | typeof MLMarkdownField, props: object) {
|
function mlMount(component: typeof MLTextField | typeof MLMarkdownField | typeof MLTextareaField, props: object) {
|
||||||
return mount(component, {
|
return mount(component, {
|
||||||
props: props as never,
|
props: props as never,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
@@ -198,3 +199,77 @@ describe('ML field registry and nested save body', () => {
|
|||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: '# Hello', pl: '# Witaj' }])
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: '# Hello', pl: '# Witaj' }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('mltextarea field', () => {
|
||||||
|
function excerpt(size?: string): FormField {
|
||||||
|
return { name: 'excerpt', label: 'Excerpt', type: 'mltextarea', ...(size ? { size } : {}) } as FormField
|
||||||
|
}
|
||||||
|
|
||||||
|
it('treats mltext, mltextarea and mlmarkdown as ML field types', () => {
|
||||||
|
expect(isMLFieldType('mltext')).toBe(true)
|
||||||
|
expect(isMLFieldType('mltextarea')).toBe(true)
|
||||||
|
expect(isMLFieldType('mlmarkdown')).toBe(true)
|
||||||
|
expect(isMLFieldType('text')).toBe(false)
|
||||||
|
expect(isMLFieldType('textarea')).toBe(false)
|
||||||
|
expect(isMLFieldType('markdown')).toBe(false)
|
||||||
|
expect(isMLFieldType(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends an mltextarea value as its locale record', () => {
|
||||||
|
const payload = editablePayload([excerpt('small')], {
|
||||||
|
excerpt: { en: 'Line 1\nLine 2', pl: 'Linia', de: 3 },
|
||||||
|
})
|
||||||
|
expect(payload).toEqual({ excerpt: { en: 'Line 1\nLine 2', pl: 'Linia' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds every enabled locale on create', () => {
|
||||||
|
const values = initialValues([excerpt('small')], ['en', 'pl'])
|
||||||
|
expect(values.excerpt).toEqual({ en: '', pl: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a sized textarea with one locale select and edits the active locale', async () => {
|
||||||
|
const wrapper = mlMount(MLTextareaField, {
|
||||||
|
field: excerpt('small'),
|
||||||
|
modelValue: { en: 'Line 1\nLine 2', pl: 'Linia' },
|
||||||
|
controlId: 'f-excerpt',
|
||||||
|
})
|
||||||
|
const root = wrapper.find('[data-ml-field]')
|
||||||
|
expect(root.attributes('data-ml-field')).toBe('mltextarea')
|
||||||
|
expect(root.attributes('role')).toBe('group')
|
||||||
|
expect(wrapper.findAll('[data-ml-locale]')).toHaveLength(1)
|
||||||
|
expect(wrapper.findAll('[data-ml-locale] option').map((node) => node.text())).toEqual(['en', 'pl'])
|
||||||
|
expect(wrapper.find('input').exists()).toBe(false)
|
||||||
|
const textarea = wrapper.find('textarea')
|
||||||
|
expect(textarea.attributes('rows')).toBe('4')
|
||||||
|
expect(textarea.element.value).toBe('Line 1\nLine 2')
|
||||||
|
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||||
|
expect(wrapper.find('textarea').element.value).toBe('Linia')
|
||||||
|
await wrapper.find('textarea').setValue('Nowa\nlinia')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Line 1\nLine 2', pl: 'Nowa\nlinia' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the default rows without a size', () => {
|
||||||
|
const wrapper = mlMount(MLTextareaField, {
|
||||||
|
field: excerpt(),
|
||||||
|
modelValue: { en: '', pl: '' },
|
||||||
|
controlId: 'f-excerpt',
|
||||||
|
})
|
||||||
|
expect(wrapper.find('textarea').attributes('rows')).toBe('5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows the locale chosen on another ML control', async () => {
|
||||||
|
const title = mlMount(MLTextField, {
|
||||||
|
field: field('mltext'),
|
||||||
|
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||||
|
controlId: 'f-title',
|
||||||
|
})
|
||||||
|
const body = mlMount(MLTextareaField, {
|
||||||
|
field: excerpt('small'),
|
||||||
|
modelValue: { en: 'EN', pl: 'PL' },
|
||||||
|
controlId: 'f-excerpt',
|
||||||
|
})
|
||||||
|
await title.find('[data-ml-locale]').setValue('pl')
|
||||||
|
expect((body.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl')
|
||||||
|
expect(body.find('textarea').element.value).toBe('PL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
|||||||
import PartialField from '../../src/components/form/fields/PartialField.vue'
|
import PartialField from '../../src/components/form/fields/PartialField.vue'
|
||||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||||
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||||
|
import MLTextareaField from '../../src/components/form/fields/MLTextareaField.vue'
|
||||||
import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
||||||
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
|
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
|
||||||
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
|
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
|
||||||
@@ -203,7 +204,15 @@ describe('markdown and multilingual field types (Phase 14.2.1)', () => {
|
|||||||
expect(groupLabelled('mlmarkdown')).toBe(true)
|
expect(groupLabelled('mlmarkdown')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(['mlunknown', 'MLTEXT', 'richtext', 'markdowneditor'])(
|
it('registers mltextarea as an editable group-labelled type', () => {
|
||||||
|
expect(rendererFor('mltextarea')).toBe(MLTextareaField)
|
||||||
|
expect(isRegistered('mltextarea')).toBe(true)
|
||||||
|
expect(needsRecord('mltextarea')).toBe(false)
|
||||||
|
expect(ownsLabel('mltextarea')).toBe(false)
|
||||||
|
expect(groupLabelled('mltextarea')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['mlunknown', 'MLTEXT', 'MLTEXTAREA', 'richtext', 'markdowneditor'])(
|
||||||
'falls back to UnsupportedField for unknown ML type %j',
|
'falls back to UnsupportedField for unknown ML type %j',
|
||||||
(type) => {
|
(type) => {
|
||||||
expect(isRegistered(type)).toBe(false)
|
expect(isRegistered(type)).toBe(false)
|
||||||
|
|||||||
9
modules/boardwalk/dist/assets/index-BonWcpEU.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-BonWcpEU.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
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DPCIeFlT.js"></script>
|
<script type="module" crossorigin src="./assets/index-BonWcpEU.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BUCXRqcT.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BUCXRqcT.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user