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:
77
admin/tests/list/CellValue.test.ts
Normal file
77
admin/tests/list/CellValue.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CellValue from '../../src/components/list/CellValue.vue'
|
||||
import type { ListColumn } from '../../src/api/types'
|
||||
import { listColumn } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
function cell(column: ListColumn, value: unknown) {
|
||||
return mount({ components: { CellValue }, props: [], template: '<span data-cell><CellValue :column="column" :value="value" /></span>', data: () => ({ column, value }) })
|
||||
}
|
||||
|
||||
const text = listColumn('name')
|
||||
const datetime = listColumn('created_at')
|
||||
const toggle = listColumn('active')
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('cell value (D-12)', () => {
|
||||
it.each([
|
||||
['a string', 'Blue', 'Blue'],
|
||||
['a number', 12, '12'],
|
||||
['an array', ['small', 3, null, ''], 'small, 3'],
|
||||
['a boolean true', true, 'Tak'],
|
||||
['a boolean false', false, 'Nie'],
|
||||
])('renders %s as plain text', (_label, value, expected) => {
|
||||
const wrapper = cell(text, value)
|
||||
expect(wrapper.text()).toBe(expected)
|
||||
expect(wrapper.find('[data-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
['an object', { nested: true }],
|
||||
['an empty array', []],
|
||||
])('renders %s as a muted dash', (_label, value) => {
|
||||
const wrapper = cell(text, value)
|
||||
expect(wrapper.find('[data-empty]').text()).toBe('—')
|
||||
})
|
||||
|
||||
it('never renders markup from a value', () => {
|
||||
const wrapper = cell(text, '<img src=x onerror=alert(1)>')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('formats datetimes as YYYY-MM-DD HH:mm in tabular numbers', () => {
|
||||
const wrapper = cell(datetime, '2026-09-21T14:32:00')
|
||||
expect(wrapper.text()).toBe('2026-09-21 14:32')
|
||||
expect(wrapper.find('span.tabular-nums').exists()).toBe(true)
|
||||
expect(cell(datetime, 'not a date').text()).toBe('not a date')
|
||||
expect(cell(datetime, null).find('[data-empty]').exists()).toBe(true)
|
||||
expect(cell(datetime, true).find('[data-empty]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'true', 'Tak'],
|
||||
[1, 'true', 'Tak'],
|
||||
['yes', 'true', 'Tak'],
|
||||
[false, 'false', 'Nie'],
|
||||
[0, 'false', 'Nie'],
|
||||
['0', 'false', 'Nie'],
|
||||
['FALSE', 'false', 'Nie'],
|
||||
])('renders the switch value %j as the %s pill', (value, state, label) => {
|
||||
const wrapper = cell(toggle, value)
|
||||
expect(wrapper.find('[data-switch]').attributes('data-switch')).toBe(state)
|
||||
expect(wrapper.text()).toBe(label)
|
||||
})
|
||||
|
||||
it('renders an empty switch value as a dash', () => {
|
||||
expect(cell(toggle, null).find('[data-empty]').exists()).toBe(true)
|
||||
expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
121
admin/tests/list/FilterBar.test.ts
Normal file
121
admin/tests/list/FilterBar.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import FilterBar from '../../src/components/list/FilterBar.vue'
|
||||
import type { ListFilter } from '../../src/api/types'
|
||||
import { listFilter, optionsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const MAKER = `${API}/acme/demo/widgets/filters/maker/options`
|
||||
const filters: ListFilter[] = [listFilter('active'), listFilter('created'), listFilter('maker')]
|
||||
|
||||
async function mountBar(values: Record<string, string> = {}, extra: ListFilter[] = []) {
|
||||
const calls = mockApi({ [`GET ${MAKER}`]: { body: optionsFixture.maker } })
|
||||
const wrapper = mount(FilterBar, { props: { filters: [...filters, ...extra], values, source } })
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
function options(select: ReturnType<ReturnType<typeof mount>['find']>) {
|
||||
return select.findAll('option').map((option) => [option.attributes('value'), option.text()])
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('filter bar (D-22, D-27)', () => {
|
||||
it('is a labelled group with one control per filter shape', async () => {
|
||||
const { wrapper } = await mountBar()
|
||||
expect(wrapper.find('[role="group"]').attributes('aria-label')).toBe('Filtry')
|
||||
expect(wrapper.find('select[data-filter="active"]').exists()).toBe(true)
|
||||
expect(wrapper.find('fieldset legend').text()).toBe('Created')
|
||||
expect(wrapper.find('[data-filter="created-from"]').attributes('aria-label')).toBe('Od')
|
||||
expect(wrapper.find('[data-filter="created-to"]').attributes('aria-label')).toBe('Do')
|
||||
expect(wrapper.find('select[data-filter="maker"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('offers switch choices as JSON of trueValue and falseValue, or of the declared options', async () => {
|
||||
const withOptions: ListFilter = {
|
||||
name: 'size',
|
||||
type: 'switch',
|
||||
options: [
|
||||
{ value: 's', label: 'Small' },
|
||||
{ value: 2, label: 'Two' },
|
||||
],
|
||||
}
|
||||
const { wrapper } = await mountBar({}, [withOptions])
|
||||
expect(options(wrapper.find('select[data-filter="active"]'))).toEqual([
|
||||
['', 'Wszystkie'],
|
||||
['true', 'Tak'],
|
||||
['false', 'Nie'],
|
||||
])
|
||||
expect(options(wrapper.find('select[data-filter="size"]'))).toEqual([
|
||||
['', 'Wszystkie'],
|
||||
['"s"', 'Small'],
|
||||
['2', 'Two'],
|
||||
])
|
||||
expect(wrapper.find('select[data-filter="size"]').element.closest('label')?.textContent).toContain('size')
|
||||
|
||||
await wrapper.find('select[data-filter="active"]').setValue('false')
|
||||
await wrapper.find('select[data-filter="active"]').setValue('')
|
||||
expect(wrapper.emitted('update')).toEqual([
|
||||
['active', 'false'],
|
||||
['active', null],
|
||||
])
|
||||
})
|
||||
|
||||
it('loads scope choices once from the filter options endpoint and selects the URL value', async () => {
|
||||
const { wrapper, calls } = await mountBar({ maker: '2' })
|
||||
const requests = requestsTo(calls, 'GET', MAKER)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect([...queryOf(requests[0]!).keys()]).toEqual([])
|
||||
const select = wrapper.find('select[data-filter="maker"]')
|
||||
expect(options(select)).toEqual([
|
||||
['', 'Wszystkie'],
|
||||
['1', 'Acme Works'],
|
||||
['2', 'Bolt & Co'],
|
||||
])
|
||||
expect((select.element as HTMLSelectElement).value).toBe('2')
|
||||
await select.setValue('1')
|
||||
expect(wrapper.emitted('update')).toEqual([['maker', '1']])
|
||||
})
|
||||
|
||||
it('shows no scope choices when the options request fails', async () => {
|
||||
mockApi({ [`GET ${MAKER}`]: { status: 403, body: { error: { code: 'forbidden', message: 'x', details: {} } } } })
|
||||
const wrapper = mount(FilterBar, { props: { filters, values: {}, source } })
|
||||
await flushPromises()
|
||||
expect(options(wrapper.find('select[data-filter="maker"]'))).toEqual([['', 'Wszystkie']])
|
||||
})
|
||||
|
||||
it('writes a date range only when both ends are set and removes it when either is cleared', async () => {
|
||||
const { wrapper } = await mountBar()
|
||||
const from = wrapper.find('[data-filter="created-from"]')
|
||||
const to = wrapper.find('[data-filter="created-to"]')
|
||||
await from.setValue('2026-01-01')
|
||||
expect(wrapper.emitted('update')).toBeUndefined()
|
||||
await to.setValue('2026-02-01')
|
||||
expect(wrapper.emitted('update')).toEqual([['created', '2026-01-01..2026-02-01']])
|
||||
|
||||
await wrapper.setProps({ values: { created: '2026-01-01..2026-02-01' } })
|
||||
await to.setValue('')
|
||||
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
|
||||
await wrapper.setProps({ values: {} })
|
||||
// The half-filled pair stays on screen until both ends are cleared.
|
||||
expect((from.element as HTMLInputElement).value).toBe('2026-01-01')
|
||||
await from.setValue('')
|
||||
expect(wrapper.emitted('update')!.at(-1)).toEqual(['created', null])
|
||||
})
|
||||
|
||||
it('fills the range inputs from the URL value', async () => {
|
||||
const { wrapper } = await mountBar({ created: '2026-03-01..2026-03-31' })
|
||||
expect((wrapper.find('[data-filter="created-from"]').element as HTMLInputElement).value).toBe('2026-03-01')
|
||||
expect((wrapper.find('[data-filter="created-to"]').element as HTMLInputElement).value).toBe('2026-03-31')
|
||||
})
|
||||
})
|
||||
57
admin/tests/list/ListToolbar.test.ts
Normal file
57
admin/tests/list/ListToolbar.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ListToolbar from '../../src/components/list/ListToolbar.vue'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const base = {
|
||||
showSearch: true,
|
||||
search: 'blue',
|
||||
searchPrompt: 'Search widgets…',
|
||||
buttons: ['delete'],
|
||||
selectedCount: 0,
|
||||
selectedLabel: 'Selected 0',
|
||||
deleteLabel: 'Delete selected',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('list toolbar (D-14)', () => {
|
||||
it('has a labelled search input that emits every change', async () => {
|
||||
const wrapper = mount(ListToolbar, { props: base })
|
||||
const input = wrapper.find('[data-list-search]')
|
||||
expect(input.attributes('type')).toBe('search')
|
||||
expect(input.attributes('placeholder')).toBe('Search widgets…')
|
||||
expect((input.element as HTMLInputElement).value).toBe('blue')
|
||||
expect(wrapper.find('label .sr-only').text()).toBe('Szukaj')
|
||||
await input.setValue('red')
|
||||
expect(wrapper.emitted('update:search')).toEqual([['red']])
|
||||
})
|
||||
|
||||
it('hides the search when the schema turns it off', () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, showSearch: false } })
|
||||
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables delete without a selection and shows the pill and danger button with one', async () => {
|
||||
const wrapper = mount(ListToolbar, { props: base })
|
||||
const button = () => wrapper.find('[data-action="delete"]')
|
||||
expect(button().attributes('disabled')).toBeDefined()
|
||||
expect(button().classes()).toContain('border-border-strong')
|
||||
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ selectedCount: 2, selectedLabel: 'Selected 2' })
|
||||
expect(button().attributes('disabled')).toBeUndefined()
|
||||
expect(button().classes()).toContain('text-danger')
|
||||
expect(button().text()).toBe('Delete selected')
|
||||
expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 2')
|
||||
await button().trigger('click')
|
||||
expect(wrapper.emitted('delete')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders only the declared toolbar buttons', () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, buttons: [] } })
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
132
admin/tests/list/ListView.test.ts
Normal file
132
admin/tests/list/ListView.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import ListView from '../../src/views/ListView.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, listFixture, listSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
|
||||
[`GET ${BASE}`]: { body: listFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) {
|
||||
const envelope = clone(listSchemaFixture)
|
||||
change(envelope.data)
|
||||
return { body: envelope }
|
||||
}
|
||||
|
||||
async function selectAllAndDelete(wrapper: Awaited<ReturnType<typeof mountApp>>['wrapper']) {
|
||||
await wrapper.find('th [role="checkbox"]').trigger('click')
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('list view', () => {
|
||||
it('is the component of the list route', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
||||
expect(wrapper.findComponent(ListView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Widgets')
|
||||
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
|
||||
})
|
||||
|
||||
it('sends the URL query to the list endpoint', async () => {
|
||||
const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes())
|
||||
const [request] = requestsTo(calls, 'GET', BASE)
|
||||
const query = queryOf(request!)
|
||||
expect(query.get('search')).toBe('blue')
|
||||
expect(query.get('sort')).toBe('code')
|
||||
expect(query.get('dir')).toBe('desc')
|
||||
expect(query.get('page')).toBe('2')
|
||||
expect(query.get('filter[active]')).toBe('true')
|
||||
})
|
||||
|
||||
it('announces a failed list load as an alert and keeps the toolbar', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('announces a failed schema load and falls back to the navigation label', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/gadgets', {
|
||||
[`GET ${API}/acme/demo/gadgets/schema/list`]: serverError,
|
||||
[`GET ${API}/acme/demo/gadgets`]: { body: listFixture },
|
||||
})
|
||||
expect(wrapper.find('h1').text()).toBe('Gadgets')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the create button only when the toolbar declares it', async () => {
|
||||
const withCreate = await mountApp('/acme/demo/widgets', routes())
|
||||
expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create')
|
||||
withCreate.wrapper.unmount()
|
||||
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }),
|
||||
)
|
||||
expect(wrapper.find('[data-action="create"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({
|
||||
[`GET ${BASE}/schema/list`]: schemaWith((schema) => {
|
||||
delete schema.recordUrl
|
||||
schema.showCheckboxes = false
|
||||
schema.showSorting = false
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(wrapper.find('tbody a').exists()).toBe(false)
|
||||
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-sort]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the server message when a bulk delete is refused', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectAllAndDelete(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
|
||||
})
|
||||
|
||||
it('toasts the generic failure when the bulk delete cannot reach the server', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
routes({
|
||||
[`POST ${BASE}/bulk-delete`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectAllAndDelete(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' })
|
||||
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
84
admin/tests/list/Pagination.test.ts
Normal file
84
admin/tests/list/Pagination.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Pagination from '../../src/components/list/Pagination.vue'
|
||||
import type { ListMeta } from '../../src/api/types'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
function meta(page: number, lastPage: number, total = lastPage * 20, perPage = 20): ListMeta {
|
||||
return { page, last_page: lastPage, total, per_page: perPage }
|
||||
}
|
||||
|
||||
function pager(page: number, lastPage: number) {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(page, lastPage), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
return wrapper
|
||||
.findAll('nav > *')
|
||||
.filter((element) => !['prev', 'next'].includes(element.attributes('data-page') ?? ''))
|
||||
.map((element) => element.text())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('shows the range of the page and a labelled pager', () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(2, 3, 45), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-range]').text()).toBe('21–40 z 45')
|
||||
expect(wrapper.find('nav').attributes('aria-label')).toBe('Stronicowanie')
|
||||
expect(wrapper.find('[data-page="2"]').attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.find('[data-page="1"]').attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 1, '1'],
|
||||
[1, 5, '1 2 3 … 5'],
|
||||
[3, 5, '1 2 3 4 5'],
|
||||
[5, 10, '1 … 4 5 6 … 10'],
|
||||
[10, 10, '1 … 8 9 10'],
|
||||
[2, 3, '1 2 3'],
|
||||
])('page %i of %i shows %s', (page, last, expected) => {
|
||||
expect(pager(page, last)).toBe(expected)
|
||||
})
|
||||
|
||||
it('disables prev on the first and next on the last page and emits pages', async () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 3), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.find('[data-page="next"]').trigger('click')
|
||||
await wrapper.find('[data-page="3"]').trigger('click')
|
||||
await wrapper.find('[data-page="1"]').trigger('click')
|
||||
expect(wrapper.emitted('page')).toEqual([[2], [3]])
|
||||
|
||||
await wrapper.setProps({ meta: meta(3, 3) })
|
||||
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.find('[data-page="prev"]').trigger('click')
|
||||
expect(wrapper.emitted('page')!.at(-1)).toEqual([2])
|
||||
})
|
||||
|
||||
it('shows the plural zero-results message and no pager without rows', () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 0, 0), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-range]').text()).toBe('0 wyników')
|
||||
expect(wrapper.find('nav').exists()).toBe(false)
|
||||
const none = mount(Pagination, { props: { meta: null, loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(none.find('[data-range]').text()).toBe('0 wyników')
|
||||
})
|
||||
|
||||
it('shows the loading text and hides the pager while loading', () => {
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: true, perPageOptions: [20], perPage: 20 } })
|
||||
expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…')
|
||||
expect(wrapper.find('nav').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the per-page select only with more than one option and emits a valid choice', async () => {
|
||||
const one = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [20], perPage: 20 } })
|
||||
expect(one.find('[data-per-page]').exists()).toBe(false)
|
||||
|
||||
const wrapper = mount(Pagination, { props: { meta: meta(1, 2), loading: false, perPageOptions: [10, 20, 50], perPage: 20 } })
|
||||
const select = wrapper.find('[data-per-page]')
|
||||
expect(select.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50'])
|
||||
expect((select.element as HTMLSelectElement).value).toBe('20')
|
||||
await select.setValue('50')
|
||||
expect(wrapper.emitted('perPage')).toEqual([[50]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user