feat(14.2.1-05): hydrate ML create/GET/save through TranslatedExact
Form schema meta lists enabled locales, Show/save expand mltext maps without D-11 fallback, and the SPA seeds and adopt-merges every locale. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { FieldControlProps } from '../control'
|
||||
import TextField from './TextField.vue'
|
||||
import { FORM_ENABLED_LOCALES } from '../formContext'
|
||||
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 enabledLocales = inject(FORM_ENABLED_LOCALES, null)
|
||||
const locales = computed(() => {
|
||||
const keys = Object.keys(values.value)
|
||||
return keys.length > 0 ? keys : ['en']
|
||||
const codes = enabledLocales?.value
|
||||
return codes && codes.length > 0 ? [...codes] : []
|
||||
})
|
||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||
const copyFrom = ref('')
|
||||
|
||||
@@ -16,6 +16,9 @@ export const FORM_PATCH: InjectionKey<(name: string, value: unknown) => void> =
|
||||
/** The form schema's locale (meta.locale). */
|
||||
export const FORM_LOCALE: InjectionKey<Readonly<Ref<string>>> = Symbol('summer.form.locale')
|
||||
|
||||
/** Enabled content locales for ML controls (schema meta.enabledLocales). */
|
||||
export const FORM_ENABLED_LOCALES: InjectionKey<Readonly<Ref<readonly string[]>>> = Symbol('summer.form.enabledLocales')
|
||||
|
||||
/**
|
||||
* The form controller's plugin assets: resolves once its scripts have loaded
|
||||
* or failed, with the failed URLs (pluginAssets.loadControllerAssets).
|
||||
|
||||
@@ -92,8 +92,9 @@ export function presetValue(type: string, source: unknown): string {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** Initial values of a new record: schema defaults, toggles off, no ids. */
|
||||
export function initialValues(fields: FormField[]): AdminRecord {
|
||||
/** Initial values of a new record: schema defaults, toggles off, no ids.
|
||||
* ML fields seed an empty string for every enabled locale. */
|
||||
export function initialValues(fields: FormField[], enabledLocales: readonly string[] = []): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of fields) {
|
||||
if (field.default !== undefined) {
|
||||
@@ -103,12 +104,38 @@ export function initialValues(fields: FormField[]): AdminRecord {
|
||||
} else if (field.type === 'relation' && field.multiple) {
|
||||
out[field.name] = []
|
||||
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||
out[field.name] = {}
|
||||
const seed: Record<string, string> = {}
|
||||
for (const code of enabledLocales) {
|
||||
seed[code] = ''
|
||||
}
|
||||
out[field.name] = seed
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Content locales from form schema meta; empty when the writer is unpublished. */
|
||||
export function schemaEnabledLocales(meta: { enabledLocales?: readonly string[] } | null | undefined): readonly string[] {
|
||||
return meta?.enabledLocales ?? []
|
||||
}
|
||||
|
||||
/** Merge a GET/save ML value onto a seed map of every enabled locale. A host
|
||||
* string becomes the first enabled locale; it never replaces sibling keys. */
|
||||
export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[]): Record<string, string> {
|
||||
const seeded: Record<string, string> = {}
|
||||
for (const code of enabledLocales) {
|
||||
seeded[code] = ''
|
||||
}
|
||||
if (typeof incoming === 'string') {
|
||||
const fallback = enabledLocales[0]
|
||||
if (fallback) {
|
||||
seeded[fallback] = incoming
|
||||
}
|
||||
return seeded
|
||||
}
|
||||
return { ...seeded, ...localeRecord(incoming) }
|
||||
}
|
||||
|
||||
/** 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[]> = {}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
|
||||
import { FORM_ASSETS, FORM_ENABLED_LOCALES, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
|
||||
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
mergeMLValue,
|
||||
panelDomId,
|
||||
presetValue,
|
||||
schemaEnabledLocales,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
@@ -168,9 +170,16 @@ const dirty = computed(
|
||||
|
||||
function adopt(record: RecordEnvelope | undefined): void {
|
||||
if (record) {
|
||||
// A record response never carries a password, so every password field
|
||||
// is empty again after a save (UI-SPEC S7).
|
||||
values.value = { ...record.data }
|
||||
const locales = schemaEnabledLocales(schema.value?.meta)
|
||||
const next: AdminRecord = { ...record.data }
|
||||
for (const field of fields.value) {
|
||||
if (field.type === 'password') {
|
||||
next[field.name] = ''
|
||||
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||
next[field.name] = mergeMLValue(record.data[field.name], locales)
|
||||
}
|
||||
}
|
||||
values.value = next
|
||||
labels.value = record.meta.labels ?? {}
|
||||
}
|
||||
saved.value = snapshot(editablePayload(fields.value, values.value, mode))
|
||||
@@ -193,7 +202,7 @@ async function load(): Promise<void> {
|
||||
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
|
||||
}
|
||||
if (schema.value && mode === 'create') {
|
||||
values.value = initialValues(fields.value)
|
||||
values.value = initialValues(fields.value, schemaEnabledLocales(schema.value.meta))
|
||||
}
|
||||
adopt(recordResult?.data)
|
||||
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||
@@ -229,6 +238,7 @@ function update(name: string, value: unknown): void {
|
||||
provide(FORM_VALUES, computed(() => values.value))
|
||||
provide(FORM_PATCH, update)
|
||||
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
||||
provide(FORM_ENABLED_LOCALES, computed(() => schemaEnabledLocales(schema.value?.meta)))
|
||||
provide(FORM_ASSETS, () => assetsReady)
|
||||
provide(FORM_SESSION, {
|
||||
key: sessionKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
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 { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
|
||||
import { editablePayload, initialValues, mergeMLValue } from '../../src/components/form/formState'
|
||||
import { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
@@ -12,8 +14,23 @@ function field(type: string, name = 'title'): FormField {
|
||||
return { name, label: 'Title', type } as FormField
|
||||
}
|
||||
|
||||
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
|
||||
|
||||
function mlMount(component: typeof MLTextField | typeof MLMarkdownField, props: Record<string, unknown>) {
|
||||
return mount(component, {
|
||||
props,
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
provide: {
|
||||
[FORM_ENABLED_LOCALES as symbol]: enabledLocales,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
enabledLocales.value = ['en', 'pl']
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -45,13 +62,10 @@ describe('ML field registry and nested save body', () => {
|
||||
})
|
||||
|
||||
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 wrapper = mlMount(MLTextField, {
|
||||
field: field('mltext'),
|
||||
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||
controlId: 'f-title',
|
||||
})
|
||||
const input = wrapper.find('input')
|
||||
expect(input.element.value).toBe('Hello')
|
||||
@@ -63,13 +77,15 @@ describe('ML field registry and nested save body', () => {
|
||||
})
|
||||
|
||||
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 title = mlMount(MLTextField, {
|
||||
field: field('mltext'),
|
||||
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||
controlId: 'f-title',
|
||||
})
|
||||
const body = mount(MLMarkdownField, {
|
||||
props: { field: field('mlmarkdown', 'body'), modelValue: { en: 'EN', pl: 'PL' }, controlId: 'f-body' },
|
||||
attachTo: document.body,
|
||||
const body = mlMount(MLMarkdownField, {
|
||||
field: field('mlmarkdown', 'body'),
|
||||
modelValue: { en: 'EN', pl: 'PL' },
|
||||
controlId: 'f-body',
|
||||
})
|
||||
expect(body.findComponent(MarkdownField).exists()).toBe(true)
|
||||
await title.find('[data-ml-locale]').setValue('pl')
|
||||
@@ -92,4 +108,31 @@ describe('ML field registry and nested save body', () => {
|
||||
expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
||||
})
|
||||
|
||||
it('seeds empty maps and lists every enabled locale on create', () => {
|
||||
const fields = [field('mltext')]
|
||||
const values = initialValues(fields, ['en', 'pl'])
|
||||
expect(values.title).toEqual({ en: '', pl: '' })
|
||||
const wrapper = mlMount(MLTextField, {
|
||||
field: fields[0],
|
||||
modelValue: values.title,
|
||||
controlId: 'f-title',
|
||||
})
|
||||
expect(wrapper.findAll('[data-ml-locale] option').map((node) => node.text())).toEqual(['en', 'pl'])
|
||||
})
|
||||
|
||||
it('keeps sibling locales when adopt merges a GET host string', async () => {
|
||||
const fields = [field('mltext')]
|
||||
const merged = mergeMLValue('Hello', ['en', 'pl'])
|
||||
expect(merged).toEqual({ en: 'Hello', pl: '' })
|
||||
const wrapper = mlMount(MLTextField, {
|
||||
field: fields[0],
|
||||
modelValue: merged,
|
||||
controlId: 'f-title',
|
||||
})
|
||||
expect(wrapper.findAll('[data-ml-locale] option').map((node) => node.text())).toEqual(['en', 'pl'])
|
||||
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||
await wrapper.find('input').setValue('Witaj')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Hello', pl: 'Witaj' }])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user