Files
summercms/admin/tests/form/formState.test.ts
Jakub Zych 9f5e807af6 test(16.2-06): admin SPA coverage of plugin field types and View site
- fieldTypes: one load per script URL, failed and refused scripts
  reported, global styles enabled on every screen, empty list on failure
- registry, formState, FormField and ListView: plugin renderers, locale
  maps, notices as literal text, unknown column types as text
- AppShell: the View site link's href, target, rel and en/pl label
2026-10-10 16:03:07 +02:00

370 lines
16 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import {
changedLocales,
contextAllows,
DEFAULT_TAB,
editablePayload,
fieldErrors,
focusField,
initialValues,
isMLField,
mergeMLValue,
noticeLines,
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')
})
})
describe('noticeLines', () => {
it('maps notices to lines per field, prefixing the content locale', () => {
expect(
noticeLines([
{ field: 'content', locale: 'pl', message: '2 elements were removed: style, iframe' },
{ field: 'content', locale: 'en', message: 'A script was removed' },
{ field: 'notes', message: 'Trimmed' },
]),
).toEqual({
content: ['PL: 2 elements were removed: style, iframe', 'EN: A script was removed'],
notes: ['Trimmed'],
})
})
it('is empty without notices', () => {
expect(noticeLines(undefined)).toEqual({})
expect(noticeLines([])).toEqual({})
expect(noticeLines([{ field: 'x', message: '' }])).toEqual({})
})
})
describe('plugin field values (Phase 16.2)', () => {
const plain: FormField = { name: 'notes', type: 'notes', element: 'acme-demo-notes' }
const translatable: FormField = { name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true }
it('treats only a translatable plugin field as a locale map', () => {
expect(isMLField(translatable)).toBe(true)
expect(isMLField(plain)).toBe(false)
expect(isMLField({ type: 'notesml', translatable: true })).toBe(false)
expect(isMLField({ type: 'mltext' })).toBe(true)
expect(isMLField(undefined)).toBe(false)
})
it('seeds a translatable plugin field with every enabled locale, a plain one with nothing', () => {
expect(initialValues([translatable, plain], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } })
expect(initialValues([{ ...plain, default: '<p>x</p>' }], ['pl'])).toEqual({ notes: '<p>x</p>' })
})
it('sends a translatable value as a locale record and a plain value as it is', () => {
const values = { body: { pl: '<p>P</p>', en: '<p>E</p>', de: 7 }, notes: { blocks: [1] }, other: 'x' }
expect(editablePayload([translatable, plain], values)).toEqual({ body: { pl: '<p>P</p>', en: '<p>E</p>' }, notes: { blocks: [1] } })
expect(editablePayload([{ ...plain, readOnly: true }], values)).toEqual({})
expect(editablePayload([plain], {})).toEqual({})
})
it('groups notices per field, keeping their order, and skips malformed ones', () => {
const lines = noticeLines([
{ field: 'body', locale: 'en', message: 'second locale first' },
{ field: 'body', locale: 'pl', message: '1 element was removed: style' },
{ field: 'body', message: 'no locale' },
{ field: 'notes', locale: '', message: 'empty locale' },
{ field: '', message: 'no field name' },
null as unknown as { field: string; message: string },
{ field: 'notes', message: 42 as unknown as string },
])
expect(lines).toEqual({
body: ['EN: second locale first', 'PL: 1 element was removed: style', 'no locale'],
notes: ['empty locale'],
'': ['no field name'],
})
})
})