- relation-manager registered in the field registry; renders only on an existing record, never on create, and is never part of the save body - RelationManager: relation schema label and comment, debounced search, selectable linked list (DataTable relation variant), toolbar buttons in declared order, confirmed unlink with plural messages and toasts - RelationPickerModal: Reka Dialog (aria-modal, focus trap, Esc) over the candidates endpoint five per page, selection kept across pages, Dodaj (N) POSTs link, focus returns to the opener - admin OpenAPI documents search, sort, dir, page and per_page on the linked and candidate relation routes so the SPA sends them typed - neutral acme.demo.widgets members fixtures and relation smoke tests
340 lines
15 KiB
TypeScript
340 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
import { setBundle } from '../../src/app/i18n'
|
|
import { clearUser } from '../../src/state/useAuth'
|
|
import { setNavigation } from '../../src/state/useNavigation'
|
|
import { setSettings } from '../../src/state/useSettings'
|
|
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 options from '../fixtures/widgets.options.json'
|
|
import { API, mountApp, queryOf, requestsTo, type Route } from '../helpers'
|
|
|
|
const BASE = `${API}/acme/demo/widgets`
|
|
const RECORD = `${BASE}/1`
|
|
const MAKER_OPTIONS = `${BASE}/fields/maker/options`
|
|
const TAGS_OPTIONS = `${BASE}/fields/tags/options`
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/form`]: { body: formSchema },
|
|
[`GET ${RECORD}`]: { body: record },
|
|
[`GET ${BASE}/schema/list`]: { body: listSchema },
|
|
[`GET ${BASE}`]: { body: listRows },
|
|
[`GET ${MAKER_OPTIONS}`]: (request) => {
|
|
const query = queryOf(request)
|
|
if (query.get('search') === 'acme') {
|
|
return { body: options.makerFieldSearch }
|
|
}
|
|
return { body: query.get('page') === '2' ? options.makerFieldPage2 : options.makerField }
|
|
},
|
|
[`GET ${TAGS_OPTIONS}`]: { body: options.tagsField },
|
|
[`PUT ${RECORD}`]: async (request) => ({
|
|
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
|
|
}),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
function tabLabels(wrapper: VueWrapper): string[] {
|
|
return wrapper.findAll('[role="tab"]').map((tab) => tab.text())
|
|
}
|
|
|
|
async function save(wrapper: VueWrapper, action = 'save'): Promise<void> {
|
|
await wrapper.find(`[data-action="${action}"]`).trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
function dialog(): HTMLElement | null {
|
|
return document.body.querySelector('[data-confirm-dialog]')
|
|
}
|
|
|
|
async function answer(action: 'confirm' | 'cancel'): Promise<void> {
|
|
;(dialog()!.querySelector(`[data-action="${action}"]`) as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearUser()
|
|
setNavigation([])
|
|
setSettings([])
|
|
clearToasts()
|
|
setBundle(lang.data, 'pl')
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('tabs and context', () => {
|
|
it('groups fields into tabs with the default tab first and filters by context', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
|
|
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Members'])
|
|
const [general, details, members] = wrapper.findAll('[role="tab"]')
|
|
expect(general!.attributes('aria-selected')).toBe('true')
|
|
expect(wrapper.find('[role="tabpanel"]').attributes('aria-labelledby')).toBe(general!.attributes('id'))
|
|
expect(wrapper.find('#field-name').exists()).toBe(true)
|
|
expect(wrapper.find('#field-serial').exists()).toBe(false)
|
|
|
|
await details!.trigger('click')
|
|
expect(details!.attributes('aria-selected')).toBe('true')
|
|
expect(wrapper.find('#field-serial').exists()).toBe(true)
|
|
expect(wrapper.find('#field-batch').exists()).toBe(false)
|
|
expect(wrapper.find('#field-name').exists()).toBe(false)
|
|
|
|
// The relation manager has its own renderer (relation.smoke covers it).
|
|
await members!.trigger('click')
|
|
expect(wrapper.find('[data-relation-manager]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-unsupported-field]').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows create-context fields on create and hides update-only tabs', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
|
|
|
|
expect(wrapper.find('h1').text()).toBe('New widget')
|
|
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details'])
|
|
await wrapper.findAll('[role="tab"]')[1]!.trigger('click')
|
|
expect(wrapper.find('#field-batch').exists()).toBe(true)
|
|
expect(wrapper.find('#field-serial').exists()).toBe(false)
|
|
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('badges tabs holding invalid fields and switches to the first invalid field', async () => {
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({
|
|
[`PUT ${RECORD}`]: {
|
|
status: 422,
|
|
body: {
|
|
error: {
|
|
code: 'validation_failed',
|
|
message: 'The given data was invalid.',
|
|
details: { serial: ['The serial has already been taken.'] },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
|
|
await save(wrapper)
|
|
|
|
const [general, details] = wrapper.findAll('[role="tab"]')
|
|
expect(general!.find('[data-tab-errors]').exists()).toBe(false)
|
|
expect(details!.find('[data-tab-errors]').text()).toBe('1')
|
|
expect(details!.find('[data-tab-errors]').attributes('aria-label')).toBe('1 błąd')
|
|
expect(details!.attributes('aria-selected')).toBe('true')
|
|
expect(document.activeElement?.id).toBe('field-serial')
|
|
expect(wrapper.find('#field-serial-error').text()).toBe('The serial has already been taken.')
|
|
})
|
|
})
|
|
|
|
describe('toggles and relations', () => {
|
|
it('renders switch and checkbox toggle cards and sends their values', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
|
|
|
|
const toggle = wrapper.find('#field-active')
|
|
expect(toggle.attributes('role')).toBe('switch')
|
|
expect(toggle.attributes('aria-checked')).toBe('true')
|
|
expect(toggle.attributes('aria-describedby')).toBe('field-active-comment')
|
|
const card = wrapper.find('[data-field="active"] [data-toggle-card]')
|
|
expect(card.find('label').text()).toBe('Active')
|
|
expect(card.text()).toContain('Shown in the catalogue')
|
|
await toggle.trigger('click')
|
|
expect(wrapper.find('#field-active').attributes('aria-checked')).toBe('false')
|
|
|
|
const box = wrapper.find('#field-featured')
|
|
expect((box.element as HTMLInputElement).checked).toBe(false)
|
|
await box.setValue(true)
|
|
|
|
await save(wrapper)
|
|
const body = (await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>
|
|
expect(body.active).toBe(false)
|
|
expect(body.featured).toBe(true)
|
|
})
|
|
|
|
it('searches a single relation with emptyOption first and sends the id or null', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
|
|
const input = wrapper.find('#field-maker')
|
|
expect((input.element as HTMLInputElement).value).toBe('Bolt & Co')
|
|
expect(input.attributes('role')).toBe('combobox')
|
|
|
|
await input.trigger('click')
|
|
await flushPromises()
|
|
const first = requestsTo(calls, 'GET', MAKER_OPTIONS)[0]!
|
|
expect(queryOf(first).get('page')).toBe('1')
|
|
expect(queryOf(first).get('per_page')).toBe('20')
|
|
expect(queryOf(first).has('search')).toBe(false)
|
|
const listbox = wrapper.find('#field-maker-listbox')
|
|
expect(listbox.attributes('role')).toBe('listbox')
|
|
const labels = () => wrapper.findAll('#field-maker-listbox [role="option"]').map((option) => option.text())
|
|
expect(labels()).toEqual(['— none —', 'Acme Works', 'Bolt & Co'])
|
|
expect(wrapper.find('#field-maker-listbox [role="option"]').classes()).toContain('text-muted')
|
|
expect(wrapper.find('[data-value="2"]').attributes('aria-selected')).toBe('true')
|
|
|
|
// More results load on the next-page action.
|
|
await wrapper.find('[data-action="more-options"]').trigger('mousedown')
|
|
await flushPromises()
|
|
expect(queryOf(requestsTo(calls, 'GET', MAKER_OPTIONS)[1]!).get('page')).toBe('2')
|
|
expect(labels()).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd'])
|
|
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false)
|
|
|
|
await input.setValue('acme')
|
|
await wait(350)
|
|
await flushPromises()
|
|
const searched = requestsTo(calls, 'GET', MAKER_OPTIONS).at(-1)!
|
|
expect(queryOf(searched).get('search')).toBe('acme')
|
|
expect(queryOf(searched).get('page')).toBe('1')
|
|
expect(labels()).toEqual(['— none —', 'Acme Works'])
|
|
|
|
await wrapper.find('#field-maker-listbox [data-value="1"]').trigger('mousedown')
|
|
expect((wrapper.find('#field-maker').element as HTMLInputElement).value).toBe('Acme Works')
|
|
await save(wrapper)
|
|
expect(((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>).maker).toBe(1)
|
|
|
|
await wrapper.find('#field-maker').trigger('click')
|
|
await flushPromises()
|
|
await wrapper.find('#field-maker-listbox [data-value="empty"]').trigger('mousedown')
|
|
const field = wrapper.find('#field-maker')
|
|
expect((field.element as HTMLInputElement).value).toBe('— none —')
|
|
expect(field.classes()).toContain('text-muted')
|
|
await save(wrapper)
|
|
expect(((await requestsTo(calls, 'PUT', RECORD)[1]!.json()) as Record<string, unknown>).maker).toBeNull()
|
|
})
|
|
|
|
it('keeps multiple relation chips in order, removes and appends ids', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
|
|
const chips = () => wrapper.findAll('[data-field="tags"] [data-chip]')
|
|
expect(chips().map((chip) => chip.text())).toEqual(['RRound', 'SSmall'])
|
|
expect(chips()[0]!.find('[aria-hidden="true"]').text()).toBe('R')
|
|
|
|
await wrapper.find('[data-field="tags"] button[aria-label="Usuń: Round"]').trigger('click')
|
|
expect(chips().map((chip) => chip.find('.font-semibold').text())).toEqual(['Small'])
|
|
|
|
const input = wrapper.find('#field-tags')
|
|
expect(input.attributes('placeholder')).toBe('Dodaj…')
|
|
await input.trigger('click')
|
|
await flushPromises()
|
|
// Chosen tags are not offered again.
|
|
expect(wrapper.findAll('#field-tags-listbox [role="option"]').map((option) => option.text())).toEqual([
|
|
'Round',
|
|
'Large',
|
|
])
|
|
await wrapper.find('#field-tags-listbox [data-value="4"]').trigger('mousedown')
|
|
expect(chips().map((chip) => chip.find('.font-semibold').text())).toEqual(['Small', 'Large'])
|
|
|
|
await save(wrapper)
|
|
expect(((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>).tags).toEqual([1, 4])
|
|
})
|
|
|
|
it('shows a read-only relation as its label only and never sends it', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
|
|
const owner = wrapper.find('[data-field="owner"]')
|
|
expect(owner.find('[data-relation-readonly]').text()).toBe('dana@example.test')
|
|
expect(owner.find('input').exists()).toBe(false)
|
|
expect(owner.find('[role="combobox"]').exists()).toBe(false)
|
|
|
|
await save(wrapper)
|
|
expect(requestsTo(calls, 'GET', `${BASE}/fields/owner/options`)).toHaveLength(0)
|
|
expect((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>).not.toHaveProperty('owner')
|
|
})
|
|
})
|
|
|
|
describe('footer and lifecycle', () => {
|
|
it('confirms delete with the form message, deletes and returns to the list', async () => {
|
|
const { wrapper, router, calls } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({ [`DELETE ${RECORD}`]: { body: { data: { deleted: 1 }, meta: {} } } }),
|
|
)
|
|
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
expect(dialog()!.textContent).toContain('Delete this widget?')
|
|
await answer('cancel')
|
|
expect(requestsTo(calls, 'DELETE', RECORD)).toHaveLength(0)
|
|
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
await answer('confirm')
|
|
|
|
const [del] = requestsTo(calls, 'DELETE', RECORD)
|
|
expect(del!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
|
expect(wrapper.find('[role="status"]').text()).toContain('Widget deleted')
|
|
})
|
|
|
|
it('asks before leaving a dirty form and guards the page unload', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes())
|
|
|
|
const clean = new Event('beforeunload', { cancelable: true })
|
|
window.dispatchEvent(clean)
|
|
expect(clean.defaultPrevented).toBe(false)
|
|
|
|
await wrapper.find('#field-name').setValue('Changed')
|
|
const unload = new Event('beforeunload', { cancelable: true })
|
|
window.dispatchEvent(unload)
|
|
expect(unload.defaultPrevented).toBe(true)
|
|
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
expect(dialog()!.textContent).toContain('Masz niezapisane zmiany. Porzucić je?')
|
|
await answer('cancel')
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
|
|
|
|
await wrapper.find('[data-action="back"]').trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do listy')
|
|
await answer('confirm')
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
|
})
|
|
|
|
it('leaves a clean form without asking', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes())
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
expect(dialog()).toBeNull()
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
|
})
|
|
|
|
it('creates a record and follows the mapped create redirect', async () => {
|
|
const { wrapper, router, calls } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
routes({
|
|
[`POST ${BASE}`]: async (request) => ({
|
|
status: 201,
|
|
body: { data: { id: 9, ...((await request.json()) as object) }, meta: { labels: {} } },
|
|
}),
|
|
[`GET ${BASE}/9`]: { body: { data: { ...record.data, id: 9, name: 'Fresh widget' }, meta: record.meta } },
|
|
}),
|
|
)
|
|
expect((wrapper.find('#field-active').attributes('aria-checked'))).toBe('false')
|
|
await wrapper.find('#field-name').setValue('Fresh widget')
|
|
await save(wrapper)
|
|
|
|
const [post] = requestsTo(calls, 'POST', BASE)
|
|
const body = (await post!.json()) as Record<string, unknown>
|
|
expect(body).toMatchObject({ name: 'Fresh widget', active: false, featured: false, tags: [] })
|
|
expect(body).not.toHaveProperty('serial')
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/9')
|
|
expect(wrapper.find('[role="status"]').text()).toContain('Widget „Fresh widget” saved')
|
|
expect(wrapper.find('h1').text()).toBe('Fresh widget')
|
|
})
|
|
|
|
it('saves and closes to the mapped redirectClose with the toast on the list', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes())
|
|
await wrapper.find('#field-name').setValue('Closed widget')
|
|
await save(wrapper, 'save-close')
|
|
|
|
expect(dialog()).toBeNull()
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
|
expect(wrapper.find('h1').text()).toBe('Widgets')
|
|
expect(wrapper.find('[role="status"]').text()).toContain('Widget „Closed widget” saved')
|
|
})
|
|
})
|