- FormTabs groups fields by tab (untabbed fields in the default tab) as a
segmented tablist; a tab holding invalid fields after a 422 shows a
count badge and the form switches to the first invalid field
- switch, checkbox and relation join the renderer registry: toggle cards
(role=switch, 20px checkbox) keep a numeric value numeric; relation
fields are read-only labels from meta.labels, a searchable single
select over fields/{field}/options with emptyOption first, or ordered
removable chips with an appending search (300 ms debounce, 20 per
page, more on scroll or the more action)
- FormView gets the back button, record title and update subtitle, a
sticky footer (Usuń with deleteConfirm then DELETE, Anuluj, Zapisz i
zamknij to the mapped redirectClose, Zapisz), and asks before leaving a
dirty form on any route change plus a beforeunload guard
- Settings: the rail pins Ustawienia to the bottom when /settings is
non-empty; /settings lists pages by category; /settings/:code renders
the settings schema through FormGrid and the registry, PUTs the values
and maps a 422 like the record form
- New backend::lang keys (form.more_options, tab_default, discard,
settings.back); form and settings smoke tests; boardwalk/dist rebuilt
219 lines
9.5 KiB
TypeScript
219 lines
9.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { flushPromises } from '@vue/test-utils'
|
|
import { interpolate, loadStrings, message, setBundle, t, tc } from '../../src/app/i18n'
|
|
import { mapWinterUrl } from '../../src/app/winterUrl'
|
|
import { clearUser } from '../../src/state/useAuth'
|
|
import { setNavigation } from '../../src/state/useNavigation'
|
|
import { clearToasts } from '../../src/state/useToasts'
|
|
import lang from '../fixtures/lang.json'
|
|
import formSchema from '../fixtures/widgets.form-schema.json'
|
|
import record from '../fixtures/widgets.record.json'
|
|
import listSchema from '../fixtures/widgets.list-schema.json'
|
|
import listRows from '../fixtures/widgets.list.json'
|
|
import { API, mockApi, mountApp, pathOf, requestsTo, type Route } from '../helpers'
|
|
|
|
const RECORD = `${API}/acme/demo/widgets/1`
|
|
|
|
const formRoutes: Record<string, Route> = {
|
|
[`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema },
|
|
[`GET ${RECORD}`]: { body: record },
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearUser()
|
|
setNavigation([])
|
|
clearToasts()
|
|
setBundle(lang.data, 'pl')
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('strings', () => {
|
|
it('loads the backend::lang bundle and sets the document language', async () => {
|
|
setBundle({}, 'en')
|
|
const calls = mockApi({ [`GET ${API}/lang`]: { body: lang } })
|
|
|
|
await loadStrings()
|
|
|
|
expect(calls.map(pathOf)).toEqual([`${API}/lang`])
|
|
expect(document.documentElement.lang).toBe('pl')
|
|
expect(t('backend::lang.form.save')).toBe('Zapisz')
|
|
expect(t('backend::lang.form.missing_key')).toBe('backend::lang.form.missing_key')
|
|
})
|
|
|
|
it('interpolates :name, :Name and :NAME like phrasebook.interpolate', () => {
|
|
expect(interpolate('Hello :name, :Name, :NAME', { name: 'ala' })).toBe('Hello ala, Ala, ALA')
|
|
// Longer placeholders win, so :names is not eaten by :name.
|
|
expect(interpolate(':names and :name', { name: 'x', names: 'y' })).toBe('y and x')
|
|
expect(interpolate('Item :name', { ':name': 'żaba' })).toBe('Item żaba')
|
|
expect(interpolate(':Name', { name: 'żaba' })).toBe('Żaba')
|
|
expect(interpolate(':count items', { count: 3 })).toBe('3 items')
|
|
expect(interpolate('no placeholders', {})).toBe('no placeholders')
|
|
})
|
|
|
|
it('picks the CLDR plural form for the locale and falls back to other', () => {
|
|
expect(tc('backend::lang.form.error_fields', 1)).toBe('Popraw 1 pole oznaczone poniżej.')
|
|
expect(tc('backend::lang.form.error_fields', 2)).toBe('Popraw 2 pola oznaczone poniżej.')
|
|
expect(tc('backend::lang.form.error_fields', 5)).toBe('Popraw 5 pól oznaczonych poniżej.')
|
|
expect(message({ other: ':count things' }, 1)).toBe('1 things')
|
|
})
|
|
})
|
|
|
|
describe('winter URLs', () => {
|
|
it('maps recordUrl and redirects onto the controller routes only', () => {
|
|
const id = 'acme.demo.widgets'
|
|
expect(mapWinterUrl('acme/demo/widgets', id)).toBe('/acme/demo/widgets')
|
|
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
|
|
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
|
|
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
|
|
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets')
|
|
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
|
|
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
|
|
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
|
|
})
|
|
})
|
|
|
|
describe('edit a record', () => {
|
|
it('opens the record route from a list row', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets', {
|
|
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
|
|
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
|
|
...formRoutes,
|
|
})
|
|
|
|
const firstRow = wrapper.findAll('tbody tr')[0]!
|
|
expect(firstRow.find('td a').attributes('href')).toBe('/acme/demo/widgets/1')
|
|
expect(firstRow.findAll('td').at(-2)!.classes()).not.toContain('font-semibold')
|
|
|
|
await firstRow.findAll('td').at(-1)!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
|
|
expect(wrapper.find('h1').text()).toBe('Blue widget')
|
|
expect(wrapper.text()).toContain('Editing widget')
|
|
})
|
|
|
|
it('renders the schema fields with their values and marks required fields', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes)
|
|
|
|
const name = wrapper.find('#field-name')
|
|
expect((name.element as HTMLInputElement).value).toBe('Blue widget')
|
|
expect(name.attributes('aria-required')).toBe('true')
|
|
expect(wrapper.find('label[for="field-name"] [aria-hidden="true"]').text()).toBe('*')
|
|
expect((wrapper.find('#field-weight').element as HTMLInputElement).value).toBe('12')
|
|
expect(wrapper.find('#field-notes').attributes('rows')).toBe('4')
|
|
const size = wrapper.find('#field-size')
|
|
expect(size.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Medium', 'Large'])
|
|
expect((size.element as HTMLSelectElement).value).toBe('1')
|
|
expect(wrapper.find('#field-code').attributes('aria-describedby')).toBe('field-code-comment')
|
|
expect(wrapper.find('#field-code-comment').text()).toBe('Unique widget code')
|
|
})
|
|
|
|
it('PUTs the typed body keyed by field name and shows the saved toast', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', {
|
|
...formRoutes,
|
|
[`PUT ${RECORD}`]: async (request) => ({
|
|
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: { labels: {} } },
|
|
}),
|
|
})
|
|
|
|
await wrapper.find('#field-name').setValue('Red widget')
|
|
await wrapper.find('#field-size').setValue('2')
|
|
await wrapper.find('#field-weight').setValue('')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
const [put] = requestsTo(calls, 'PUT', RECORD)
|
|
expect(put).toBeDefined()
|
|
expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
|
// Every editable, context-allowed field; not the unsupported colorpicker,
|
|
// the read-only owner or the relation manager.
|
|
expect(await put!.json()).toEqual({
|
|
name: 'Red widget',
|
|
code: 'W-01',
|
|
size: 'l',
|
|
weight: null,
|
|
notes: 'Round',
|
|
active: true,
|
|
featured: false,
|
|
maker: 2,
|
|
tags: [3, 1],
|
|
serial: 'SN-1',
|
|
})
|
|
const toast = wrapper.find('[role="status"]')
|
|
expect(toast.text()).toContain('Widget „Red widget” saved')
|
|
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')
|
|
})
|
|
|
|
it('maps a 422 onto fields, focuses the first invalid one and clears on change', async () => {
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
{
|
|
...formRoutes,
|
|
[`PUT ${RECORD}`]: {
|
|
status: 422,
|
|
body: {
|
|
error: {
|
|
code: 'validation_failed',
|
|
message: 'The given data was invalid.',
|
|
details: { weight: ['The weight must be positive.'], name: ['The name field is required.'] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ attach: true },
|
|
)
|
|
|
|
await wrapper.find('#field-name').setValue('')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
const banner = wrapper.find('[data-error-banner]')
|
|
expect(banner.attributes('role')).toBe('alert')
|
|
expect(banner.text()).toContain('Nie udało się zapisać.')
|
|
expect(banner.text()).toContain('Popraw 2 pola oznaczone poniżej.')
|
|
const name = wrapper.find('#field-name')
|
|
expect(name.attributes('aria-invalid')).toBe('true')
|
|
expect(name.attributes('aria-describedby')).toBe('field-name-error')
|
|
expect(wrapper.find('#field-name-error').text()).toBe('The name field is required.')
|
|
expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true')
|
|
// Schema order decides which invalid field is first, not the details order.
|
|
expect(document.activeElement?.id).toBe('field-name')
|
|
|
|
await name.setValue('Fixed')
|
|
expect(wrapper.find('#field-name').attributes('aria-invalid')).toBeUndefined()
|
|
expect(wrapper.find('#field-name-error').exists()).toBe(false)
|
|
expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true')
|
|
expect(wrapper.find('[data-error-banner]').text()).toContain('Popraw 1 pole oznaczone poniżej.')
|
|
})
|
|
|
|
it('shows a danger toast with the envelope message for other errors', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', {
|
|
...formRoutes,
|
|
[`PUT ${RECORD}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden.', details: {} } } },
|
|
})
|
|
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
const toast = wrapper.find('[data-tone="danger"]')
|
|
expect(toast.attributes('role')).toBe('alert')
|
|
expect(toast.text()).toContain('Forbidden.')
|
|
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders an unknown field type as the unsupported box instead of breaking the form', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes)
|
|
|
|
const box = wrapper.find('[data-unsupported-field]')
|
|
expect(box.exists()).toBe(true)
|
|
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
|
|
expect(box.find('code').text()).toBe('colorpicker')
|
|
expect(box.find('code').classes()).toContain('font-mono')
|
|
expect(box.find('svg').exists()).toBe(true)
|
|
expect(wrapper.find('label[for="field-color"]').text()).toBe('Colour')
|
|
})
|
|
})
|