test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
143
admin/tests/list/DataTable.test.ts
Normal file
143
admin/tests/list/DataTable.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import DataTable from '../../src/components/list/DataTable.vue'
|
||||
import type { AdminRecord, ListColumn } from '../../src/api/types'
|
||||
import type { RowId } from '../../src/app/listQuery'
|
||||
import { linkedFixture, listColumn, listFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
const columns: ListColumn[] = [listColumn('name'), listColumn('code'), listColumn('tags')]
|
||||
|
||||
async function mountTable(props: Record<string, unknown>) {
|
||||
await signIn()
|
||||
const router = await routerAt('/acme/demo/widgets')
|
||||
const wrapper = mount(DataTable, {
|
||||
props: { columns, rows: listFixture.data, ...props },
|
||||
global: { plugins: [router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const rowLink = (row: AdminRecord) => `/acme/demo/widgets/${String(row.id)}`
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('data table', () => {
|
||||
it('renders exactly the schema columns and each record through its column keys', async () => {
|
||||
const { wrapper } = await mountTable({})
|
||||
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
|
||||
expect(wrapper.findAll('th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
|
||||
expect(cells).toEqual([
|
||||
['Blue widget', 'W-01', 'small, round'],
|
||||
['Green widget', 'W-02', '—'],
|
||||
])
|
||||
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBeUndefined()
|
||||
expect(wrapper.find('th').attributes('aria-sort')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks sortable headers with aria-sort none, ascending or descending and emits the column', async () => {
|
||||
const { wrapper } = await mountTable({ sortable: true, sort: { column: 'code', dir: 'desc' } })
|
||||
const headers = wrapper.findAll('th')
|
||||
expect(headers.map((th) => th.attributes('aria-sort'))).toEqual(['none', 'descending', undefined])
|
||||
expect(headers[2]!.find('button').exists()).toBe(false)
|
||||
await wrapper.setProps({ sort: { column: 'name', dir: 'asc' } })
|
||||
expect(wrapper.findAll('th').map((th) => th.attributes('aria-sort'))).toEqual(['ascending', 'none', undefined])
|
||||
await wrapper.find('[data-sort="code"]').trigger('click')
|
||||
expect(wrapper.emitted('sort')).toEqual([['code']])
|
||||
})
|
||||
|
||||
it('selects rows through a tri-state header checkbox over the current page', async () => {
|
||||
const { wrapper } = await mountTable({ selectable: true, selected: [] as RowId[] })
|
||||
const header = () => wrapper.find('th[data-select] [role="checkbox"]')
|
||||
expect(header().attributes('aria-checked')).toBe('false')
|
||||
expect(header().attributes('aria-label')).toBe('Zaznacz wszystkie na stronie')
|
||||
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'false'])
|
||||
|
||||
await header().trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
|
||||
|
||||
await wrapper.setProps({ selected: ['2'] })
|
||||
expect(header().attributes('aria-checked')).toBe('mixed')
|
||||
expect(wrapper.findAll('tbody tr').map((tr) => tr.attributes('aria-selected'))).toEqual(['false', 'true'])
|
||||
await header().trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[1, 2]])
|
||||
|
||||
await wrapper.setProps({ selected: [1, 2] })
|
||||
expect(header().attributes('aria-checked')).toBe('true')
|
||||
await header().trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
|
||||
})
|
||||
|
||||
it('toggles single rows by id', async () => {
|
||||
const { wrapper } = await mountTable({ selectable: true, selected: [2] as RowId[] })
|
||||
const boxes = wrapper.findAll('tbody [role="checkbox"]')
|
||||
expect(boxes.map((box) => box.attributes('aria-checked'))).toEqual(['false', 'true'])
|
||||
expect(boxes[0]!.attributes('aria-label')).toBe('Zaznacz wiersz')
|
||||
await boxes[0]!.trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[2, 1]])
|
||||
await boxes[1]!.trigger('click')
|
||||
expect(wrapper.emitted('update:selected')!.at(-1)).toEqual([[]])
|
||||
})
|
||||
|
||||
it('opens the row link on a row click but not from its controls', async () => {
|
||||
const { wrapper, router } = await mountTable({ selectable: true, rowLink })
|
||||
expect(wrapper.find('tbody tr td:nth-child(2) a').attributes('href')).toBe('/acme/demo/widgets/1')
|
||||
await wrapper.find('tbody tr [role="checkbox"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
await wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/2')
|
||||
})
|
||||
|
||||
it('does nothing on a row click without a row link and keeps rows without ids unselectable', async () => {
|
||||
const { wrapper, router } = await mountTable({
|
||||
selectable: true,
|
||||
rows: [{ name: 'No id', code: 'X', tags: [] }],
|
||||
})
|
||||
await wrapper.find('tbody td:nth-child(2)').trigger('click')
|
||||
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
expect(wrapper.emitted('update:selected')).toBeUndefined()
|
||||
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows eight skeleton rows with aria-busy while loading and the empty slot without rows', async () => {
|
||||
const { wrapper } = await mountTable({ loading: true, selectable: true })
|
||||
expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8)
|
||||
expect(wrapper.find('th [role="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.setProps({ loading: false, rows: [] })
|
||||
expect(wrapper.find('tbody').attributes('aria-busy')).toBeUndefined()
|
||||
expect(wrapper.find('tbody td').attributes('colspan')).toBe('4')
|
||||
expect(wrapper.find('tbody td').text()).toBe('Brak rekordów w tym widoku.')
|
||||
})
|
||||
|
||||
it('renders the relation variant with 56px rows, initials avatars and three skeleton rows', async () => {
|
||||
const relationColumns = [
|
||||
{ key: 'name', label: 'Name', searchable: true, sortable: true },
|
||||
{ key: 'email', label: 'E-mail', searchable: true, sortable: true },
|
||||
]
|
||||
const { wrapper } = await mountTable({ variant: 'relation', columns: relationColumns, rows: linkedFixture.all.data })
|
||||
const first = wrapper.find('tbody tr')
|
||||
expect(first.classes()).toContain('h-[56px]')
|
||||
expect(first.find('[data-avatar]').text()).toBe('AB')
|
||||
expect(first.find('[data-avatar]').attributes('aria-hidden')).toBe('true')
|
||||
expect(first.findAll('td')[1]!.classes()).toContain('text-muted')
|
||||
|
||||
await wrapper.setProps({ loading: true })
|
||||
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user