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:
Jakub Zych
2026-10-06 15:37:00 +02:00
parent ed81f2a0c1
commit 0f8d13738d
11 changed files with 309 additions and 34 deletions

View File

@@ -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('')

View File

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

View File

@@ -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[]> = {}

View File

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

View File

@@ -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' }])
})
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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