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) { 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) }) }) describe('row states in the table (UI-SPEC S4, D-12)', () => { const stateLabels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Not active' } const rows = (wrapper: Awaited>['wrapper']) => wrapper.findAll('tbody tr') const firstCell = (wrapper: Awaited>['wrapper'], index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]! it('leaves every row unchanged without states', async () => { const { wrapper } = await mountTable({ rowLink, stateLabels }) for (const row of rows(wrapper)) { expect(row.attributes('data-row-states')).toBeUndefined() expect(row.find('[data-row-state]').exists()).toBe(false) } expect(firstCell(wrapper, 0).find('a').classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline'])) const empty = await mountTable({ rowLink, stateLabels, rowStates: { '1': [] } }) expect(rows(empty.wrapper)[0]!.attributes('data-row-states')).toBeUndefined() }) it.each([ ['deleted', ['line-through', 'text-muted'], ['text-danger']], ['negative', ['no-underline', 'text-danger'], ['line-through']], ['disabled', ['no-underline', 'text-muted'], ['line-through', 'text-danger']], ])('styles the first-cell text of a %s row and says the state in a badge', async (state, present, absent) => { const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '1': [state] } }) const row = rows(wrapper)[0]! expect(row.attributes('data-row-states')).toBe(state) const link = firstCell(wrapper, 0).find('a') expect(link.classes()).toEqual(expect.arrayContaining(present)) for (const name of absent) { expect(link.classes()).not.toContain(name) } const badge = firstCell(wrapper, 0).find(`[data-row-state="${state}"]`) expect(badge.text()).toBe(stateLabels[state as keyof typeof stateLabels]) // The badge sits beside the link, never inside the struck text. expect(link.find('[data-row-state]').exists()).toBe(false) // The other row is untouched. expect(rows(wrapper)[1]!.attributes('data-row-states')).toBeUndefined() }) it('combines states: the attribute lists them and danger wins over muted', async () => { const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '1': ['deleted', 'negative', 'disabled'] } }) expect(rows(wrapper)[0]!.attributes('data-row-states')).toBe('deleted negative disabled') const link = firstCell(wrapper, 0).find('a') expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger'])) expect(firstCell(wrapper, 0).findAll('[data-row-state]').map((badge) => badge.attributes('data-row-state'))).toEqual(['deleted', 'negative', 'disabled']) // Every other cell of a row with a state is muted. expect(rows(wrapper)[0]!.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted') }) it('styles plain text the same way when the row has no link', async () => { const { wrapper } = await mountTable({ stateLabels, rowStates: { '1': ['deleted'] } }) const cell = firstCell(wrapper, 0) expect(cell.find('a').exists()).toBe(false) expect(cell.find('span.line-through').classes()).toEqual(expect.arrayContaining(['line-through', 'text-muted', 'truncate'])) expect(cell.find('[data-row-state="deleted"]').text()).toBe('Deleted') }) it('keys states by the row id as text and ignores ids that are not on the page', async () => { const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '2': ['disabled'], '99': ['deleted'], abc: ['negative'] } }) expect(rows(wrapper)[0]!.attributes('data-row-states')).toBeUndefined() expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('disabled') expect(wrapper.findAll('[data-row-state]')).toHaveLength(1) }) it('backstop: a row state outside the fixed set renders no badge and no class', async () => { const hostile = ['starred', 'bg-danger-soft', 'line-through', 'x', 'DELETED', 'deleted negative'] const { wrapper } = await mountTable({ rowLink, stateLabels: { ...stateLabels, starred: 'Starred' }, rowStates: { '1': hostile } }) const row = rows(wrapper)[0]! expect(row.attributes('data-row-states')).toBeUndefined() expect(row.find('[data-row-state]').exists()).toBe(false) expect(row.html()).not.toMatch(/starred|Starred|bg-danger-soft|x<\/b>|DELETED/) const link = firstCell(wrapper, 0).find('a') expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline'])) expect(link.classes()).not.toContain('line-through') expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted') // Beside a known state, only the known one counts. const mixed = await mountTable({ rowLink, stateLabels, rowStates: { '1': ['starred', 'disabled', 'x'] } }) expect(rows(mixed.wrapper)[0]!.attributes('data-row-states')).toBe('disabled') expect(rows(mixed.wrapper)[0]!.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Not active']) expect(rows(mixed.wrapper)[0]!.html()).not.toContain('starred') }) }) describe('invisible columns (D-27 G6)', () => { it('renders no header and no cell for an invisible column, and keeps the first visible column first', async () => { const hidden = [{ ...listColumn('name'), invisible: true }, listColumn('code'), { ...listColumn('tags'), invisible: true }] const { wrapper } = await mountTable({ rowLink, columns: hidden }) expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Code']) const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text())) expect(cells).toEqual([['W-01'], ['W-02']]) // The value of an invisible column is nowhere in the markup. expect(wrapper.html()).not.toContain('Blue widget') // The first visible column carries the row link. expect(wrapper.find('tbody tr td a').text()).toBe('W-01') }) it('treats invisible false and a missing flag as visible', async () => { const shown = [{ ...listColumn('name'), invisible: false }, listColumn('code')] const { wrapper } = await mountTable({ columns: shown }) expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code']) }) })