Files
summercms/admin/tests/form/formState.test.ts
Jakub Zych a714bb4629 fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first
plain text field only, so records named by an mltext field fell back to
the generic update message. A shared recordDisplayName helper now takes
the first text or ML field and reads its default locale, then the first
non-empty one.
2026-10-07 19:05:47 +02:00

305 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import {
changedLocales,
contextAllows,
DEFAULT_TAB,
editablePayload,
fieldErrors,
focusField,
initialValues,
mergeMLValue,
panelDomId,
presetUpdates,
presetValue,
recordDisplayName,
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 = ''
})
describe('form state helpers', () => {
it('keys tabs by label with a default tab for untabbed fields', () => {
expect(tabOf(formField('name'))).toBe(DEFAULT_TAB)
expect(tabOf(formField('serial'))).toBe('tab:Details')
expect(tabDomId('field', 2)).toBe('field-tab-2')
expect(panelDomId('field', 2)).toBe('field-panel-2')
})
it('applies Winter context semantics', () => {
expect(contextAllows(formField('name'), 'create')).toBe(true)
expect(contextAllows(formField('serial'), 'update')).toBe(true)
expect(contextAllows(formField('serial'), 'create')).toBe(false)
expect(contextAllows(formField('batch'), 'create')).toBe(true)
expect(contextAllows(formField('batch'), 'update')).toBe(false)
expect(contextAllows({ ...formField('name'), context: [] }, 'update')).toBe(true)
expect(contextAllows({ ...formField('name'), context: null as unknown as undefined }, 'update')).toBe(true)
})
it('sends only editable, registered fields that hold a value', () => {
const fields = formSchemaFixture.data.fields
const body = editablePayload(fields, {
name: 'Blue',
code: undefined,
color: '#fff',
owner: 5,
members: [1],
tags: [3],
active: false,
unknown_key: 'x',
})
expect(body).toEqual({ name: 'Blue', tags: [3], active: false })
})
it('never sends widget or partial fields, even with a value under their name', () => {
const fields = [formField('name'), ...extensionFormSchemaFixture.data.fields.filter((field) => field.type === 'widget' || field.type === 'partial')]
expect(fields.map((field) => field.type)).toEqual(['text', 'widget', 'partial'])
expect(editablePayload(fields, { name: 'Blue', lookup: 'x', summary: { nodes: [] } })).toEqual({ name: 'Blue' })
expect(initialValues(fields.slice(1), [])).toEqual({})
})
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
const fields = [
{ ...formField('name'), default: 'Unnamed' },
formField('active'),
formField('featured'),
formField('tags'),
formField('maker'),
{ ...formField('weight'), default: 0 },
]
expect(initialValues(fields, [])).toEqual({ name: 'Unnamed', active: false, featured: false, tags: [], weight: 0 })
})
it('normalizes 422 details into string lists and drops empty entries', () => {
expect(
fieldErrors({
name: ['Required', '', 3],
code: 'Taken',
empty: [],
blank: '',
nested: { a: 1 },
}),
).toEqual({ name: ['Required'], code: ['Taken'] })
expect(fieldErrors(undefined)).toEqual({})
})
it('snapshots records independently of key order', () => {
expect(snapshot({ a: 1, b: [2] })).toBe(snapshot({ b: [2], a: 1 }))
expect(snapshot({ a: 1 })).not.toBe(snapshot({ a: 2 }))
})
it('focuses a control by its id, making a non-control focusable first', () => {
document.body.innerHTML = '<input id="field-name"><div id="field-members"></div>'
expect(focusField('field', 'name')).toBe(true)
expect(document.activeElement?.id).toBe('field-name')
expect(focusField('field', 'members')).toBe(true)
expect(document.getElementById('field-members')?.getAttribute('tabindex')).toBe('-1')
expect(document.activeElement?.id).toBe('field-members')
expect(focusField('field', 'missing')).toBe(false)
})
})
describe('password and permission values in the save body (UI-SPEC S5 and S7)', () => {
const fields = rosterFormSchemaFixture.data.fields
it('leaves an empty password out on update and sends it as entered on create', () => {
const pair = fields.filter((field) => field.type === 'password')
expect(pair.map((field) => field.name)).toEqual(['password', 'password_confirmation'])
expect(editablePayload(pair, { password: '', password_confirmation: '' }, 'update')).toEqual({})
expect(editablePayload(pair, { password: null, password_confirmation: null }, 'update')).toEqual({})
expect(editablePayload(pair, { password: '', password_confirmation: '' }, 'create')).toEqual({ password: '', password_confirmation: '' })
expect(editablePayload(pair, { password: 'new-secret', password_confirmation: 'new-secret' }, 'update')).toEqual({
password: 'new-secret',
password_confirmation: 'new-secret',
})
// A lone password is sent as entered: the server's confirmed rule answers.
expect(editablePayload(pair, { password: 'new-secret', password_confirmation: '' }, 'update')).toEqual({ password: 'new-secret' })
// The default mode is create.
expect(editablePayload(pair, { password: '' })).toEqual({ password: '' })
})
it('starts password fields empty, whatever the record holds', () => {
const initial = initialValues(fields.filter((field) => field.type === 'password'), [])
expect(initial.password ?? '').toBe('')
expect(initial.password_confirmation ?? '').toBe('')
})
it('shows a field with context preview on the preview only, and hides create and update fields there', () => {
const byName = (name: string) => fields.find((field) => field.name === name)!
expect(contextAllows(byName('joined_ip'), 'preview')).toBe(true)
expect(contextAllows(byName('joined_ip'), 'update')).toBe(false)
expect(contextAllows(byName('joined_ip'), 'create')).toBe(false)
expect(contextAllows(byName('password'), 'preview')).toBe(false)
expect(contextAllows(byName('notify'), 'preview')).toBe(false)
expect(contextAllows(byName('permissions'), 'preview')).toBe(false)
expect(contextAllows(byName('name'), 'preview')).toBe(true)
})
})
describe('preset values (D-27 G7)', () => {
// The same table as the user plugin's slug test (models/slug_test.go,
// slugCases): the form and the model derive the same slug from a name.
const slugCases: Array<[string, string]> = [
['Acme Records', 'acme-records'],
[' Acme -- Records ', 'acme-records'],
['ACME_Records 2', 'acme-records-2'],
['Zażółć gęślą', 'za-g-l'],
['Rock & Roll!!!', 'rock-roll'],
['...leading and trailing...', 'leading-and-trailing'],
['tab\tand\nnewline', 'tab-and-newline'],
['emoji 🎵 music', 'emoji-music'],
['CamelCaseText', 'camelcasetext'],
['9 lives', '9-lives'],
['a', 'a'],
['---', ''],
[' ', ''],
['', ''],
['ąęść', ''],
['Long Name '.repeat(40), 'long-name-'.repeat(40).replace(/-$/, '')],
]
it.each(slugCases)('slug preset matches the server rule: %j', (text, want) => {
expect(presetValue('slug', text)).toBe(want)
// A slug is a fixed point of the rule.
expect(presetValue('slug', want)).toBe(want)
})
it('copies the text for type exact and for any type it does not know', () => {
expect(presetValue('exact', ' Acme -- Records ')).toBe(' Acme -- Records ')
expect(presetValue('camel', 'Acme Records')).toBe('Acme Records')
})
it('reads a number as its text and anything else as empty', () => {
expect(presetValue('slug', 2026)).toBe('2026')
expect(presetValue('exact', 7)).toBe('7')
for (const source of [null, undefined, true, { a: 1 }, ['x']]) {
expect(presetValue('slug', source)).toBe('')
expect(presetValue('exact', source)).toBe('')
}
})
})
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 = [
{ name: 'title', type: 'mltext', label: 'Title' } as ReturnType<typeof formField>,
{ name: 'body', type: 'mlmarkdown', label: 'Body' } as ReturnType<typeof formField>,
]
expect(initialValues(fields, ['en', 'pl'])).toEqual({
title: { en: '', pl: '' },
body: { en: '', pl: '' },
})
expect(initialValues(fields, [])).toEqual({ title: {}, body: {} })
})
it('merges a GET host string onto the seed without dropping sibling locales', () => {
expect(mergeMLValue('Hello', ['en', 'pl'])).toEqual({ en: 'Hello', pl: '' })
expect(mergeMLValue({ en: 'Hello', pl: 'Witaj' }, ['en', 'pl'])).toEqual({ en: 'Hello', pl: 'Witaj' })
})
})
describe('recordDisplayName', () => {
const field = (name: string, type: string): FormField => ({ ...formField('name'), name, type })
const text = field('name', 'text')
const title = field('title', 'mltext')
const locales = ['en', 'pl']
it('reads a plain text value, trimmed', () => {
expect(recordDisplayName([text], { name: ' Anna ' }, locales)).toBe('Anna')
expect(recordDisplayName([text], { name: 42 }, locales)).toBe('42')
})
it('reads the default locale of an ML value', () => {
expect(recordDisplayName([title], { title: { en: 'Hello', pl: 'Cześć' } }, locales)).toBe('Hello')
})
it('falls back to the first non-empty locale when the default is empty', () => {
expect(recordDisplayName([title], { title: { en: ' ', pl: 'Cześć' } }, locales)).toBe('Cześć')
})
it('is empty when every locale is empty', () => {
expect(recordDisplayName([title], { title: { en: '', pl: '' } }, locales)).toBe('')
expect(recordDisplayName([title], {}, locales)).toBe('')
})
it('skips fields that are not text-like, in favour of a later text field', () => {
const fields = [field('notify', 'checkbox'), field('tags', 'relation'), title, text]
expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post')
})
})