import { afterEach, describe, expect, it } from 'vitest' import { contextAllows, DEFAULT_TAB, editablePayload, fieldErrors, focusField, initialValues, panelDomId, presetValue, snapshot, tabDomId, tabOf, } from '../../src/components/form/formState' import { extensionFormSchemaFixture, formField, formSchemaFixture, rosterFormSchemaFixture } from '../fixtures/typed' 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 = '
' 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('') } }) })