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:
Jakub Zych
2026-10-06 12:32:54 +02:00
parent 234111d245
commit a04116d41f
13 changed files with 895 additions and 3 deletions

View 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>

View 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>

View 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>

View File

@@ -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

View 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
}

View File

@@ -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