feat(admin): follow presets on mltext fields per locale

- formState: changedLocales and presetUpdates hold the preset-follow rules
  for text and mltext pairs
- FormView applies presetUpdates on create, tracks per-locale hand edits of
  ML fields and follows the active ML locale
- rebuilt modules/boardwalk/dist
This commit is contained in:
Jakub Zych
2026-10-06 21:18:09 +02:00
parent 7afad732c7
commit b5d850662c
7 changed files with 301 additions and 22 deletions

View File

@@ -553,4 +553,81 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
await field(update.wrapper, 'name').setValue('Ada King')
expect(field(update.wrapper, 'slug').element.value).toBe('ada-lovelace')
})
/** The roster schema with en/pl content locales and name/slug retyped. */
function mlSchema(nameType: string, slugType: string) {
const envelope = clone(rosterFormSchemaFixture)
envelope.data.meta.enabledLocales = ['en', 'pl']
for (const item of envelope.data.fields) {
if (item.name === 'name') {
item.type = nameType
} else if (item.name === 'slug') {
item.type = slugType
}
}
return { body: envelope }
}
const editor = (wrapper: Mounted, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}-editor`)
const localeSelect = (wrapper: Mounted, name: string) => wrapper.find<HTMLSelectElement>(`[data-field="${name}"] [data-ml-locale]`)
const invalidCreate = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: {} } } }
it('follows an mltext source per locale on create, and stops only a locale edited by hand', async () => {
const { wrapper, calls } = await mountApp(
'/acme/roster/people/create',
people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'mltext'), [`POST ${PEOPLE}`]: invalidCreate }),
)
await editor(wrapper, 'name').setValue('Zażółć gęślą')
expect(editor(wrapper, 'slug').element.value).toBe('za-g-l')
await localeSelect(wrapper, 'name').setValue('pl')
await editor(wrapper, 'name').setValue('Witaj Świecie')
expect(localeSelect(wrapper, 'slug').element.value).toBe('pl')
expect(editor(wrapper, 'slug').element.value).toBe('witaj-wiecie')
await localeSelect(wrapper, 'name').setValue('en')
expect(editor(wrapper, 'slug').element.value).toBe('za-g-l')
// A hand edit of the pl slug stops pl only.
await localeSelect(wrapper, 'slug').setValue('pl')
await editor(wrapper, 'slug').setValue('reczny')
await editor(wrapper, 'name').setValue('Inny Tytuł')
expect(editor(wrapper, 'slug').element.value).toBe('reczny')
await localeSelect(wrapper, 'name').setValue('en')
await editor(wrapper, 'name').setValue('Hello World')
expect(editor(wrapper, 'slug').element.value).toBe('hello-world')
await save(wrapper)
const body = (await requestsTo(calls, 'POST', PEOPLE)[0]!.clone().json()) as Record<string, unknown>
expect(body.name).toEqual({ en: 'Hello World', pl: 'Inny Tytuł' })
expect(body.slug).toEqual({ en: 'hello-world', pl: 'reczny' })
})
it('does not follow an mltext source on update', async () => {
const record = clone(rosterRecordFixture)
record.data.name = { en: 'Ada Lovelace', pl: '' }
record.data.slug = { en: 'ada-lovelace', pl: '' }
const { wrapper } = await mountApp('/acme/roster/people/1', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'mltext'), [`GET ${PERSON}`]: { body: record } }))
await editor(wrapper, 'name').setValue('Ada King')
expect(editor(wrapper, 'slug').element.value).toBe('ada-lovelace')
})
it('drives a text slug from the active locale of an mltext name, until edited', async () => {
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text') }))
await editor(wrapper, 'name').setValue('Hello World')
expect(field(wrapper, 'slug').element.value).toBe('hello-world')
await localeSelect(wrapper, 'name').setValue('pl')
await editor(wrapper, 'name').setValue('Witaj Świecie')
expect(field(wrapper, 'slug').element.value).toBe('witaj-wiecie')
await field(wrapper, 'slug').setValue('by-hand')
await editor(wrapper, 'name').setValue('Inny')
expect(field(wrapper, 'slug').element.value).toBe('by-hand')
})
it('fills only the active locale of an mltext slug from a text name', async () => {
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('text', 'mltext') }))
await field(wrapper, 'name').setValue('Hello World')
expect(editor(wrapper, 'slug').element.value).toBe('hello-world')
await localeSelect(wrapper, 'slug').setValue('pl')
expect(editor(wrapper, 'slug').element.value).toBe('')
})
})

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
changedLocales,
contextAllows,
DEFAULT_TAB,
editablePayload,
@@ -8,12 +9,14 @@ import {
initialValues,
mergeMLValue,
panelDomId,
presetUpdates,
presetValue,
snapshot,
tabDomId,
tabOf,
} from '../../src/components/form/formState'
import { extensionFormSchemaFixture, formField, formSchemaFixture, rosterFormSchemaFixture } from '../fixtures/typed'
import type { FormField } from '../../src/api/types'
afterEach(() => {
document.body.innerHTML = ''
@@ -180,6 +183,76 @@ describe('preset values (D-27 G7)', () => {
})
})
describe('presets on mltext fields', () => {
function presetFields(sourceType: string, targetType: string, presetType = 'slug'): FormField[] {
const source = formField('name')
source.type = sourceType
const target = formField('name')
target.name = 'slug'
target.type = targetType
target.preset = { field: 'name', type: presetType }
return [source, target]
}
const never = () => false
it('lists the locales whose text changed', () => {
expect(changedLocales({ en: '', pl: '' }, { en: 'A', pl: '' })).toEqual(['en'])
expect(changedLocales({ en: 'A', pl: 'B' }, { en: 'A', pl: 'B' })).toEqual([])
expect(changedLocales({ en: '' }, { en: '', pl: 'B' })).toEqual(['pl'])
expect(changedLocales({ en: 'A', pl: '' }, { en: 'A' })).toEqual([])
expect(changedLocales(undefined, { en: 'A' })).toEqual(['en'])
expect(changedLocales('text', { en: 'A', pl: '' })).toEqual(['en'])
})
it('keeps text -> text as before, and leaves an edited target out', () => {
const fields = presetFields('text', 'text')
expect(presetUpdates(fields, 'name', '', 'Hello World', {}, 'en', never)).toEqual({ slug: 'hello-world' })
expect(presetUpdates(fields, 'name', '', 'Hello World', {}, 'en', (target) => target === 'slug')).toEqual({})
expect(presetUpdates(fields, 'slug', '', 'x', {}, 'en', never)).toEqual({})
})
it('follows ML -> ML per locale and stops only an edited locale', () => {
const fields = presetFields('mltext', 'mltext')
const values = { name: { en: '', pl: '' }, slug: { en: '', pl: 'kept' } }
expect(presetUpdates(fields, 'name', { en: '', pl: '' }, { en: 'Hello World', pl: '' }, values, 'en', never)).toEqual({
slug: { en: 'hello-world', pl: 'kept' },
})
const later = { name: { en: 'Hello World', pl: '' }, slug: { en: 'hello-world', pl: '' } }
expect(presetUpdates(fields, 'name', later.name, { en: 'Hello World', pl: 'Witaj Świecie' }, later, 'pl', never)).toEqual({
slug: { en: 'hello-world', pl: 'witaj-wiecie' },
})
const plEdited = (target: string, locale?: string) => target === 'slug' && locale === 'pl'
expect(presetUpdates(fields, 'name', later.name, { en: 'Hello World', pl: 'Witaj' }, later, 'pl', plEdited)).toEqual({})
expect(presetUpdates(fields, 'name', later.name, { en: 'Other', pl: '' }, later, 'pl', plEdited)).toEqual({
slug: { en: 'other', pl: '' },
})
const exact = presetFields('mltext', 'mltext', 'exact')
expect(presetUpdates(exact, 'name', {}, { en: 'Hello World' }, {}, 'en', never)).toEqual({ slug: { en: 'Hello World' } })
expect(presetUpdates(fields, 'name', { en: 'A' }, { en: 'A' }, values, 'en', never)).toEqual({})
})
it('drives a text target from the ML source active locale', () => {
const fields = presetFields('mltext', 'text')
expect(presetUpdates(fields, 'name', {}, { en: 'Hello World', pl: 'Witaj' }, {}, 'en', never)).toEqual({ slug: 'hello-world' })
expect(presetUpdates(fields, 'name', {}, { en: 'Hello World', pl: 'Witaj' }, {}, 'pl', never)).toEqual({ slug: 'witaj' })
expect(presetUpdates(fields, 'name', {}, { en: 'Hello World' }, {}, 'de', never)).toEqual({ slug: '' })
expect(presetUpdates(fields, 'name', {}, { en: 'Hello World' }, {}, 'en', (target) => target === 'slug')).toEqual({})
})
it('writes only the active locale of an ML target from a text source', () => {
const fields = presetFields('text', 'mltext')
const values = { slug: { en: '', pl: 'kept' } }
expect(presetUpdates(fields, 'name', '', 'Hello World', values, 'en', never)).toEqual({ slug: { en: 'hello-world', pl: 'kept' } })
expect(presetUpdates(fields, 'name', '', 'Hello World', values, 'en', (target, locale) => target === 'slug' && locale === 'en')).toEqual({})
})
it('ignores a preset on any other target type', () => {
for (const type of ['mlmarkdown', 'checkbox', 'markdown']) {
expect(presetUpdates(presetFields('mltext', type), 'name', {}, { en: 'A' }, {}, 'en', never)).toEqual({})
}
})
})
describe('ML create seed and adopt merge', () => {
it('seeds every enabled locale as an empty string', () => {
const fields = [