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">
|
<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 type { FieldControlProps } from '../control'
|
||||||
import TextField from './TextField.vue'
|
import TextField from './TextField.vue'
|
||||||
|
import { FORM_ENABLED_LOCALES } from '../formContext'
|
||||||
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
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 values = computed(() => localeRecord(props.modelValue))
|
||||||
|
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
|
||||||
const locales = computed(() => {
|
const locales = computed(() => {
|
||||||
const keys = Object.keys(values.value)
|
const codes = enabledLocales?.value
|
||||||
return keys.length > 0 ? keys : ['en']
|
return codes && codes.length > 0 ? [...codes] : []
|
||||||
})
|
})
|
||||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||||
const copyFrom = ref('')
|
const copyFrom = ref('')
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const FORM_PATCH: InjectionKey<(name: string, value: unknown) => void> =
|
|||||||
/** The form schema's locale (meta.locale). */
|
/** The form schema's locale (meta.locale). */
|
||||||
export const FORM_LOCALE: InjectionKey<Readonly<Ref<string>>> = Symbol('summer.form.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
|
* The form controller's plugin assets: resolves once its scripts have loaded
|
||||||
* or failed, with the failed URLs (pluginAssets.loadControllerAssets).
|
* or failed, with the failed URLs (pluginAssets.loadControllerAssets).
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ export function presetValue(type: string, source: unknown): string {
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initial values of a new record: schema defaults, toggles off, no ids. */
|
/** Initial values of a new record: schema defaults, toggles off, no ids.
|
||||||
export function initialValues(fields: FormField[]): AdminRecord {
|
* ML fields seed an empty string for every enabled locale. */
|
||||||
|
export function initialValues(fields: FormField[], enabledLocales: readonly string[] = []): AdminRecord {
|
||||||
const out: AdminRecord = {}
|
const out: AdminRecord = {}
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (field.default !== undefined) {
|
if (field.default !== undefined) {
|
||||||
@@ -103,12 +104,38 @@ export function initialValues(fields: FormField[]): AdminRecord {
|
|||||||
} 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 (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
|
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. */
|
/** D-10 422 details (field -> messages) as a string-list map. */
|
||||||
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
||||||
const out: 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 FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||||
import FormGrid from '../components/form/FormGrid.vue'
|
import FormGrid from '../components/form/FormGrid.vue'
|
||||||
import FormTabs from '../components/form/FormTabs.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 { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
||||||
import {
|
import {
|
||||||
DEFAULT_TAB,
|
DEFAULT_TAB,
|
||||||
@@ -22,8 +22,10 @@ import {
|
|||||||
fieldErrors,
|
fieldErrors,
|
||||||
focusField,
|
focusField,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
mergeMLValue,
|
||||||
panelDomId,
|
panelDomId,
|
||||||
presetValue,
|
presetValue,
|
||||||
|
schemaEnabledLocales,
|
||||||
snapshot,
|
snapshot,
|
||||||
tabDomId,
|
tabDomId,
|
||||||
tabOf,
|
tabOf,
|
||||||
@@ -168,9 +170,16 @@ const dirty = computed(
|
|||||||
|
|
||||||
function adopt(record: RecordEnvelope | undefined): void {
|
function adopt(record: RecordEnvelope | undefined): void {
|
||||||
if (record) {
|
if (record) {
|
||||||
// A record response never carries a password, so every password field
|
const locales = schemaEnabledLocales(schema.value?.meta)
|
||||||
// is empty again after a save (UI-SPEC S7).
|
const next: AdminRecord = { ...record.data }
|
||||||
values.value = { ...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 ?? {}
|
labels.value = record.meta.labels ?? {}
|
||||||
}
|
}
|
||||||
saved.value = snapshot(editablePayload(fields.value, values.value, mode))
|
saved.value = snapshot(editablePayload(fields.value, values.value, mode))
|
||||||
@@ -193,7 +202,7 @@ async function load(): Promise<void> {
|
|||||||
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
|
assetsReady = loadControllerAssets(controllerId, schema.value.assets)
|
||||||
}
|
}
|
||||||
if (schema.value && mode === 'create') {
|
if (schema.value && mode === 'create') {
|
||||||
values.value = initialValues(fields.value)
|
values.value = initialValues(fields.value, schemaEnabledLocales(schema.value.meta))
|
||||||
}
|
}
|
||||||
adopt(recordResult?.data)
|
adopt(recordResult?.data)
|
||||||
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
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_VALUES, computed(() => values.value))
|
||||||
provide(FORM_PATCH, update)
|
provide(FORM_PATCH, update)
|
||||||
provide(FORM_LOCALE, computed(() => schema.value?.meta.locale || currentLocale.value))
|
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_ASSETS, () => assetsReady)
|
||||||
provide(FORM_SESSION, {
|
provide(FORM_SESSION, {
|
||||||
key: sessionKey,
|
key: sessionKey,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
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 MLTextField from '../../src/components/form/fields/MLTextField.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 { isRegistered, rendererFor } from '../../src/components/form/registry'
|
||||||
import { resetState } from '../helpers'
|
import { resetState } from '../helpers'
|
||||||
|
|
||||||
@@ -12,8 +14,23 @@ function field(type: string, name = 'title'): FormField {
|
|||||||
return { name, label: 'Title', type } as 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(() => {
|
beforeEach(() => {
|
||||||
resetState()
|
resetState()
|
||||||
|
enabledLocales.value = ['en', 'pl']
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -45,13 +62,10 @@ describe('ML field registry and nested save body', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('edits the active locale and copies from another', async () => {
|
it('edits the active locale and copies from another', async () => {
|
||||||
const wrapper = mount(MLTextField, {
|
const wrapper = mlMount(MLTextField, {
|
||||||
props: {
|
|
||||||
field: field('mltext'),
|
field: field('mltext'),
|
||||||
modelValue: { en: 'Hello', pl: 'Witaj' },
|
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||||
controlId: 'f-title',
|
controlId: 'f-title',
|
||||||
},
|
|
||||||
attachTo: document.body,
|
|
||||||
})
|
})
|
||||||
const input = wrapper.find('input')
|
const input = wrapper.find('input')
|
||||||
expect(input.element.value).toBe('Hello')
|
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 () => {
|
it('switches every ML control from one selector', async () => {
|
||||||
const title = mount(MLTextField, {
|
const title = mlMount(MLTextField, {
|
||||||
props: { field: field('mltext'), modelValue: { en: 'Hello', pl: 'Witaj' }, controlId: 'f-title' },
|
field: field('mltext'),
|
||||||
attachTo: document.body,
|
modelValue: { en: 'Hello', pl: 'Witaj' },
|
||||||
|
controlId: 'f-title',
|
||||||
})
|
})
|
||||||
const body = mount(MLMarkdownField, {
|
const body = mlMount(MLMarkdownField, {
|
||||||
props: { field: field('mlmarkdown', 'body'), modelValue: { en: 'EN', pl: 'PL' }, controlId: 'f-body' },
|
field: field('mlmarkdown', 'body'),
|
||||||
attachTo: document.body,
|
modelValue: { en: 'EN', pl: 'PL' },
|
||||||
|
controlId: 'f-body',
|
||||||
})
|
})
|
||||||
expect(body.findComponent(MarkdownField).exists()).toBe(true)
|
expect(body.findComponent(MarkdownField).exists()).toBe(true)
|
||||||
await title.find('[data-ml-locale]').setValue('pl')
|
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((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined()
|
||||||
expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('<script>')
|
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' }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -441,6 +441,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
|
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -700,7 +703,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result, err = projectFullRecord(ctx, tx, cc, target)
|
result, err = projectFullRecord(ctx, tx, cc, target)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RecordResult{}, err
|
return RecordResult{}, err
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ const (
|
|||||||
mlMarkdownFieldType = "mlmarkdown"
|
mlMarkdownFieldType = "mlmarkdown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TranslationWriter persists non-default locale values during a cabana save.
|
// TranslationWriter persists non-default locale values during a cabana save
|
||||||
// The interface lives in the framework so cabana never imports a plugin; a
|
// and reads exact stored translations for Show/save hydration. The interface
|
||||||
// host publishes an adapter at Boot. There is no standalone translate-write
|
// lives in the framework so cabana never imports a plugin; a host publishes
|
||||||
|
// an adapter at Boot. There is no standalone translate-read or translate-write
|
||||||
// HTTP endpoint: writes run only inside a permissioned, scoped host save
|
// HTTP endpoint: writes run only inside a permissioned, scoped host save
|
||||||
// transaction after the host row has a primary key.
|
// transaction after the host row has a primary key, and reads run on the
|
||||||
|
// same adapter after projectFullRecord.
|
||||||
type TranslationWriter interface {
|
type TranslationWriter interface {
|
||||||
// DefaultLocale is the host-column locale.
|
// DefaultLocale is the host-column locale.
|
||||||
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
|
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
|
||||||
@@ -25,6 +27,11 @@ type TranslationWriter interface {
|
|||||||
// WriteTranslated stores field in locale for model. locale is never the
|
// WriteTranslated stores field in locale for model. locale is never the
|
||||||
// default; cabana already filled the host scalar.
|
// default; cabana already filled the host scalar.
|
||||||
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
|
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
|
||||||
|
// TranslatedExact returns the stored value for field in locale. ok is
|
||||||
|
// false when the non-default key is missing. It must not apply D-11
|
||||||
|
// fallback: empty admin values stay empty. The default locale still
|
||||||
|
// reads the host column.
|
||||||
|
TranslatedExact(ctx context.Context, tx *gorm.DB, model any, field, locale string) (string, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mlValue is one multilingual field lifted from a save body.
|
// mlValue is one multilingual field lifted from a save body.
|
||||||
@@ -158,3 +165,68 @@ func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWri
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hydrateMLRecord replaces host scalars of declared mltext/mlmarkdown fields
|
||||||
|
// in a Show/save RecordResult.Data map with a locale→text map for every
|
||||||
|
// enabled code. The default locale is the projected host column; other codes
|
||||||
|
// come from TranslatedExact; a missing non-default code is an empty string,
|
||||||
|
// never D-11 fallback. It is a no-op when writer is nil and is not used from
|
||||||
|
// list row projection.
|
||||||
|
func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, writer TranslationWriter, model any, data map[string]any, op string) error {
|
||||||
|
if writer == nil || cc == nil || cc.Form == nil || data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, field := range cc.Form.Fields {
|
||||||
|
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, field.Name)
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defaultLocale, err := writer.DefaultLocale(ctx, tx)
|
||||||
|
if err != nil || defaultLocale == "" {
|
||||||
|
return fmt.Errorf("cabana: default locale: %w", err)
|
||||||
|
}
|
||||||
|
codes, err := writer.EnabledLocales(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cabana: enabled locales: %w", err)
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
host := hostScalarString(data[name])
|
||||||
|
out := make(map[string]string, len(codes))
|
||||||
|
for _, code := range codes {
|
||||||
|
if code == defaultLocale {
|
||||||
|
out[code] = host
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, ok, err := writer.TranslatedExact(ctx, tx, model, name, code)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
out[code] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[code] = text
|
||||||
|
}
|
||||||
|
data[name] = out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostScalarString(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case []byte:
|
||||||
|
return string(t)
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -709,10 +709,16 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
view.Fields = kept
|
view.Fields = kept
|
||||||
view.Assets = s.controllerAssets(cc)
|
view.Assets = s.controllerAssets(cc)
|
||||||
|
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
|
||||||
|
view.Meta.EnabledLocales = append([]string(nil), codes...)
|
||||||
|
}
|
||||||
meta := map[string]any{}
|
meta := map[string]any{}
|
||||||
if view.Meta.Locale != "" {
|
if view.Meta.Locale != "" {
|
||||||
meta["locale"] = view.Meta.Locale
|
meta["locale"] = view.Meta.Locale
|
||||||
}
|
}
|
||||||
|
if len(view.Meta.EnabledLocales) > 0 {
|
||||||
|
meta["enabledLocales"] = view.Meta.EnabledLocales
|
||||||
|
}
|
||||||
WriteData(w, http.StatusOK, view, meta)
|
WriteData(w, http.StatusOK, view, meta)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -939,6 +945,29 @@ func (s *service) crud() (CRUDService, error) {
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enabledContentLocales is TranslationWriter.EnabledLocales after the same
|
||||||
|
// Lookup crud() uses. Empty when the adapter is unpublished or the query
|
||||||
|
// fails; list schemas never call this.
|
||||||
|
func (s *service) enabledContentLocales(ctx context.Context) []string {
|
||||||
|
if s == nil || s.app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writer, ok := s.app.Lookup[TranslationWriter]()
|
||||||
|
if !ok || writer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
codes, err := writer.EnabledLocales(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("cabana: enabled locales", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||||
s.protect(w, r, func(cc *CompiledController) {
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
db, err := s.db()
|
db, err := s.db()
|
||||||
|
|||||||
@@ -95,6 +95,25 @@ func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model a
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *recordingWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
|
||||||
|
if locale == w.defaultLocale {
|
||||||
|
post, _ := model.(*mlPost)
|
||||||
|
if post == nil {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return post.Title, true, nil
|
||||||
|
}
|
||||||
|
if w.attrs == nil {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
vals := w.attrs[locale]
|
||||||
|
if vals == nil {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
text, ok := vals[field]
|
||||||
|
return text, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
func mlFS() fstest.MapFS {
|
func mlFS() fstest.MapFS {
|
||||||
return fstest.MapFS{
|
return fstest.MapFS{
|
||||||
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)},
|
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)},
|
||||||
@@ -181,8 +200,13 @@ func TestMLNestedSaveSmoke(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
if rec["title"] != "Hello" {
|
if rec["title"] != nil {
|
||||||
t.Fatalf("projected title = %#v, want Hello", rec["title"])
|
got, ok := rec["title"].(map[string]string)
|
||||||
|
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
||||||
|
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
|
||||||
}
|
}
|
||||||
var row mlPost
|
var row mlPost
|
||||||
if err := db.First(&row).Error; err != nil {
|
if err := db.First(&row).Error; err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cabana
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -66,7 +67,13 @@ func TestMLNestedSave(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
if rec["title"] != "Hello" {
|
if rec["title"] != nil {
|
||||||
|
want := map[string]string{"en": "Hello", "pl": "Witaj"}
|
||||||
|
got, ok := rec["title"].(map[string]string)
|
||||||
|
if !ok || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("projected title = %#v", rec["title"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
t.Fatalf("projected title = %#v", rec["title"])
|
t.Fatalf("projected title = %#v", rec["title"])
|
||||||
}
|
}
|
||||||
var row mlPost
|
var row mlPost
|
||||||
@@ -97,6 +104,55 @@ func TestMLNestedSave(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMLHydration(t *testing.T) {
|
||||||
|
_, db := newListService(t)
|
||||||
|
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mlPost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||||
|
svc := CRUDService{DB: db, writer: writer}
|
||||||
|
cc := mlCompiled(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := svc.CreateRecord(ctx, cc, RecordInput{Body: map[string]any{
|
||||||
|
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
got, ok := created.Data["title"].(map[string]string)
|
||||||
|
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
||||||
|
t.Fatalf("create title = %#v", created.Data["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
shown, err := svc.ShowRecord(ctx, cc, created.Data["id"])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("show: %v", err)
|
||||||
|
}
|
||||||
|
got, ok = shown.Data["title"].(map[string]string)
|
||||||
|
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
||||||
|
t.Fatalf("show title = %#v", shown.Data["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
englishOnly := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||||
|
svcOnly := CRUDService{DB: db, writer: englishOnly}
|
||||||
|
row := mlPost{Title: "Hello"}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shown, err = svcOnly.ShowRecord(ctx, cc, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("show english-only: %v", err)
|
||||||
|
}
|
||||||
|
got, ok = shown.Data["title"].(map[string]string)
|
||||||
|
if !ok || got["en"] != "Hello" || got["pl"] != "" {
|
||||||
|
t.Fatalf("english-only title = %#v", shown.Data["title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestML(t *testing.T) {
|
func TestML(t *testing.T) {
|
||||||
t.Run("writer failure rolls back host write", func(t *testing.T) {
|
t.Run("writer failure rolls back host write", func(t *testing.T) {
|
||||||
_, db := newListService(t)
|
_, db := newListService(t)
|
||||||
|
|||||||
@@ -227,8 +227,11 @@ type FormRedirect struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FormMeta reports the locale selected for a schema response.
|
// FormMeta reports the locale selected for a schema response.
|
||||||
|
// EnabledLocales lists content locales for ML fields when a TranslationWriter
|
||||||
|
// is published; omitempty keeps list schemas without the key.
|
||||||
type FormMeta struct {
|
type FormMeta struct {
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
|
EnabledLocales []string `json:"enabledLocales,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormField is one Winter field in source order. JSON keys keep Winter spelling.
|
// FormField is one Winter field in source order. JSON keys keep Winter spelling.
|
||||||
|
|||||||
Reference in New Issue
Block a user