feat(14.2.1-03): add markdown and multilingual cabana fields
Lift locale maps before ProjectWritableFields so a Journal-shaped save can persist the default host scalar and non-default locales through TranslationWriter without dropping nested JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
109
admin/src/components/form/fields/MLMarkdownField.vue
Normal file
109
admin/src/components/form/fields/MLMarkdownField.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { FieldControlProps } from '../control'
|
||||
import MarkdownField from './MarkdownField.vue'
|
||||
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||
|
||||
const values = computed(() => localeRecord(props.modelValue))
|
||||
const locales = computed(() => {
|
||||
const keys = Object.keys(values.value)
|
||||
return keys.length > 0 ? keys : ['en']
|
||||
})
|
||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||
const copyFrom = ref('')
|
||||
|
||||
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||
const editorId = computed(() => `${props.controlId}-editor`)
|
||||
const activeText = computed(() => values.value[activeLocale.value] ?? '')
|
||||
const otherLocales = computed(() => locales.value.filter((code) => code !== activeLocale.value))
|
||||
const markdownField = computed(() => ({ ...props.field, type: 'markdown' }))
|
||||
|
||||
function setLocale(locale: string) {
|
||||
if (!locale) {
|
||||
return
|
||||
}
|
||||
activeLocale.value = locale
|
||||
if (!copyFrom.value || copyFrom.value === locale) {
|
||||
copyFrom.value = otherLocales.value[0] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
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 copyLocale() {
|
||||
const from = copyFrom.value
|
||||
if (!from || from === activeLocale.value) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', { ...values.value, [activeLocale.value]: values.value[from] ?? '' })
|
||||
}
|
||||
|
||||
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)
|
||||
copyFrom.value = otherLocales.value[0] ?? ''
|
||||
})
|
||||
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="mlmarkdown">
|
||||
<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>
|
||||
<select
|
||||
v-if="otherLocales.length > 0"
|
||||
data-ml-copy-from
|
||||
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||
:value="copyFrom"
|
||||
aria-label="Copy from locale"
|
||||
@change="copyFrom = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="code in otherLocales" :key="code" :value="code">{{ code }}</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="otherLocales.length > 0"
|
||||
type="button"
|
||||
data-ml-copy
|
||||
class="rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
||||
@click="copyLocale"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<MarkdownField
|
||||
:field="markdownField"
|
||||
:model-value="activeText"
|
||||
:control-id="editorId"
|
||||
:invalid="invalid"
|
||||
:described-by="describedBy"
|
||||
@update:model-value="onText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
108
admin/src/components/form/fields/MLTextField.vue
Normal file
108
admin/src/components/form/fields/MLTextField.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { FieldControlProps } from '../control'
|
||||
import TextField from './TextField.vue'
|
||||
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||
|
||||
const values = computed(() => localeRecord(props.modelValue))
|
||||
const locales = computed(() => {
|
||||
const keys = Object.keys(values.value)
|
||||
return keys.length > 0 ? keys : ['en']
|
||||
})
|
||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||
const copyFrom = ref('')
|
||||
|
||||
const labelledBy = computed(() => `${props.controlId}-label`)
|
||||
const editorId = computed(() => `${props.controlId}-editor`)
|
||||
const activeText = computed(() => values.value[activeLocale.value] ?? '')
|
||||
const otherLocales = computed(() => locales.value.filter((code) => code !== activeLocale.value))
|
||||
|
||||
function setLocale(locale: string) {
|
||||
if (!locale) {
|
||||
return
|
||||
}
|
||||
activeLocale.value = locale
|
||||
if (!copyFrom.value || copyFrom.value === locale) {
|
||||
copyFrom.value = otherLocales.value[0] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
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 copyLocale() {
|
||||
const from = copyFrom.value
|
||||
if (!from || from === activeLocale.value) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', { ...values.value, [activeLocale.value]: values.value[from] ?? '' })
|
||||
}
|
||||
|
||||
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)
|
||||
copyFrom.value = otherLocales.value[0] ?? ''
|
||||
})
|
||||
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="mltext">
|
||||
<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>
|
||||
<select
|
||||
v-if="otherLocales.length > 0"
|
||||
data-ml-copy-from
|
||||
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
|
||||
:value="copyFrom"
|
||||
aria-label="Copy from locale"
|
||||
@change="copyFrom = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="code in otherLocales" :key="code" :value="code">{{ code }}</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="otherLocales.length > 0"
|
||||
type="button"
|
||||
data-ml-copy
|
||||
class="rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
||||
@click="copyLocale"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<TextField
|
||||
:field="{ ...field, type: 'text' }"
|
||||
:model-value="activeText"
|
||||
:control-id="editorId"
|
||||
:invalid="invalid"
|
||||
:described-by="describedBy"
|
||||
@update:model-value="onText"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
51
admin/src/components/form/fields/MarkdownField.vue
Normal file
51
admin/src/components/form/fields/MarkdownField.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||
|
||||
// Source editor for type: markdown. Preview interpolates text (never v-html)
|
||||
// so translated raw HTML/script cannot execute in the admin SPA. Server
|
||||
// preview uses cabana.RenderMarkdown (goldmark without html.WithUnsafe).
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
|
||||
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 8)
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
const preview = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-markdown-preview
|
||||
class="self-start rounded-pager px-2 py-1 text-[13px] text-muted hover:bg-hover hover:text-text"
|
||||
:aria-pressed="preview ? 'true' : 'false'"
|
||||
@click="preview = !preview"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<pre
|
||||
v-if="preview"
|
||||
data-markdown-preview-pane
|
||||
:class="controlClass(invalid)"
|
||||
class="min-h-input overflow-auto px-3.5 py-2.5 whitespace-pre-wrap"
|
||||
>{{ text }}</pre>
|
||||
<textarea
|
||||
v-else
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
:rows="rows"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="min-h-input resize-y py-2.5"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,8 +2,11 @@
|
||||
// filtering, the save payload, 422 detail mapping and focus.
|
||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||
import { permissionValues } from './control'
|
||||
import { localeRecord } from './mlLocale'
|
||||
import { isRegistered } from './registry'
|
||||
|
||||
export { ML_LOCALE_CHANGE, broadcastMLLocale, 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'
|
||||
|
||||
@@ -62,6 +65,10 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
|
||||
out[field.name] = permissionValues(field, value)
|
||||
continue
|
||||
}
|
||||
if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||
out[field.name] = localeRecord(value)
|
||||
continue
|
||||
}
|
||||
if (value !== undefined) {
|
||||
out[field.name] = value
|
||||
}
|
||||
@@ -95,6 +102,8 @@ export function initialValues(fields: FormField[]): AdminRecord {
|
||||
out[field.name] = false
|
||||
} else if (field.type === 'relation' && field.multiple) {
|
||||
out[field.name] = []
|
||||
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||
out[field.name] = {}
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
24
admin/src/components/form/mlLocale.ts
Normal file
24
admin/src/components/form/mlLocale.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Window event that keeps every ML control on one active locale. */
|
||||
export const ML_LOCALE_CHANGE = 'summer:ml-locale'
|
||||
|
||||
/** Broadcast the locale the administrator selected on any ML control. */
|
||||
export function broadcastMLLocale(locale: string): void {
|
||||
if (typeof window === 'undefined' || locale === '') {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: locale }))
|
||||
}
|
||||
|
||||
/** Locale → text map as it is sent on save: only string values. */
|
||||
export function localeRecord(value: unknown): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return out
|
||||
}
|
||||
for (const [locale, text] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof text === 'string') {
|
||||
out[locale] = text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -28,6 +28,9 @@ import RelationField from './fields/RelationField.vue'
|
||||
import SwitchField from './fields/SwitchField.vue'
|
||||
import TextField from './fields/TextField.vue'
|
||||
import TextareaField from './fields/TextareaField.vue'
|
||||
import MarkdownField from './fields/MarkdownField.vue'
|
||||
import MLTextField from './fields/MLTextField.vue'
|
||||
import MLMarkdownField from './fields/MLMarkdownField.vue'
|
||||
import UnsupportedField from './fields/UnsupportedField.vue'
|
||||
import WidgetField from './fields/WidgetField.vue'
|
||||
|
||||
@@ -60,6 +63,9 @@ const renderers = new Map<string, Component>([
|
||||
['datepicker', DatepickerField],
|
||||
['password', PasswordField],
|
||||
['permissioneditor', PermissionEditorField],
|
||||
['markdown', MarkdownField],
|
||||
['mltext', MLTextField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
@@ -79,7 +85,7 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
||||
* 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.
|
||||
*/
|
||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor'])
|
||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mlmarkdown'])
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||
|
||||
95
admin/tests/form/MLFields.test.ts
Normal file
95
admin/tests/form/MLFields.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import MarkdownField from '../../src/components/form/fields/MarkdownField.vue'
|
||||
import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue'
|
||||
import MLTextField from '../../src/components/form/fields/MLTextField.vue'
|
||||
import { editablePayload } from '../../src/components/form/formState'
|
||||
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
function field(type: string, name = 'title'): FormField {
|
||||
return { name, label: 'Title', type } as FormField
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ML field registry and nested save body', () => {
|
||||
it('resolves markdown, mltext and mlmarkdown', () => {
|
||||
expect(rendererFor('markdown')).toBe(MarkdownField)
|
||||
expect(rendererFor('mltext')).toBe(MLTextField)
|
||||
expect(rendererFor('mlmarkdown')).toBe(MLMarkdownField)
|
||||
expect(isRegistered('markdown')).toBe(true)
|
||||
expect(isRegistered('mltext')).toBe(true)
|
||||
expect(isRegistered('mlmarkdown')).toBe(true)
|
||||
})
|
||||
|
||||
it('sends every locale as a nested record', () => {
|
||||
const fields = [field('mltext'), field('mlmarkdown', 'body')]
|
||||
const payload = editablePayload(fields, {
|
||||
title: { en: 'Hello', pl: 'Witaj' },
|
||||
body: { en: '# Hi', pl: '# Cześć' },
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
title: { en: 'Hello', pl: 'Witaj' },
|
||||
body: { en: '# Hi', pl: '# Cześć' },
|
||||
})
|
||||
})
|
||||
|
||||
it('edits the active locale and copies from another', async () => {
|
||||
const wrapper = mount(MLTextField, {
|
||||
props: {
|
||||
field: field('mltext'),
|
||||
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||
controlId: 'f-title',
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.element.value).toBe('Hello')
|
||||
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||
expect(wrapper.find('input').element.value).toBe('Witaj')
|
||||
await wrapper.find('[data-ml-copy-from]').setValue('en')
|
||||
await wrapper.find('[data-ml-copy]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Hello', pl: 'Hello' }])
|
||||
})
|
||||
|
||||
it('switches every ML control from one selector', async () => {
|
||||
const title = mount(MLTextField, {
|
||||
props: { field: field('mltext'), modelValue: { en: 'Hello', pl: 'Witaj' }, controlId: 'f-title' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
const body = mount(MLMarkdownField, {
|
||||
props: { field: field('mlmarkdown', 'body'), modelValue: { en: 'EN', pl: 'PL' }, controlId: 'f-body' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(body.findComponent(MarkdownField).exists()).toBe(true)
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not execute raw HTML in the markdown preview', async () => {
|
||||
const wrapper = mount(MarkdownField, {
|
||||
props: {
|
||||
field: field('markdown', 'body'),
|
||||
modelValue: '<script>window.__ml_xss = 1</script><img src=x onerror="window.__ml_xss = 1">',
|
||||
controlId: 'f-body',
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
await wrapper.find('[data-markdown-preview]').trigger('click')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user