Files
malio-layer-ui/app/components/malio/datatable/DataTable.test.ts
tristan 6720e3062a
All checks were successful
Release / release (push) Successful in 1m5s
feat: ajout du composant datatable (#28)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [x] CHANGELOG modifié

Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-authored-by: Kevin Boudet <kevin@yuno.malio.fr>
Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-16 07:06:50 +00:00

279 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue'
import DataTable from './DataTable.vue'
type DataTableProps = {
id?: string
columns?: { key: string; label: string }[]
items?: Record<string, unknown>[]
totalItems?: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
tableClass?: string
emptyMessage?: string
}
const DataTableForTest = DataTable as DefineComponent<DataTableProps>
const defaultColumns = [
{ key: 'nom', label: 'Nom' },
{ key: 'ville', label: 'Ville' },
]
const defaultItems = [
{ nom: 'Dupont', ville: 'Paris' },
{ nom: 'Martin', ville: 'Lyon' },
{ nom: 'Bernard', ville: 'Marseille' },
]
function mountComponent(props: DataTableProps = {}, slots?: Record<string, unknown>) {
return mount(DataTableForTest, {
props: {
columns: defaultColumns,
items: defaultItems,
totalItems: 3,
...props,
},
slots,
global: {
stubs: {
MalioSelect: {
name: 'MalioSelect',
template: '<div data-test="malio-select"><slot /></div>',
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
},
MalioButton: {
template: '<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\', $event)"><slot>{{ label }}</slot></button>',
props: ['label', 'disabled', 'variant', 'buttonClass'],
emits: ['click'],
inheritAttrs: true,
},
},
},
})
}
describe('MalioDataTable', () => {
describe('Table rendering', () => {
it('renders column headers as text when no header slot', () => {
const wrapper = mountComponent()
const headers = wrapper.findAll('th')
expect(headers).toHaveLength(2)
expect(headers[0].text()).toBe('Nom')
expect(headers[1].text()).toBe('Ville')
})
it('renders header slot when provided', () => {
const wrapper = mountComponent({}, {
'header-nom': '<input data-test="filter-nom" placeholder="Nom" />',
})
expect(wrapper.find('[data-test="filter-nom"]').exists()).toBe(true)
})
it('renders items as rows', () => {
const wrapper = mountComponent()
const rows = wrapper.findAll('[data-test="row"]')
expect(rows).toHaveLength(3)
expect(rows[0].text()).toContain('Dupont')
expect(rows[0].text()).toContain('Paris')
})
it('renders cell slot when provided', () => {
const wrapper = mountComponent({}, {
'cell-nom': ({ item }: { item: Record<string, unknown> }) => h('strong', String(item.nom)),
})
const firstRow = wrapper.findAll('[data-test="row"]')[0]
expect(firstRow.find('strong').text()).toBe('Dupont')
})
it('renders empty message when items is empty', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Aucune donnée')
})
it('renders custom empty message', () => {
const wrapper = mountComponent({ items: [], totalItems: 0, emptyMessage: 'Rien ici' })
expect(wrapper.find('[data-test="empty-row"]').text()).toBe('Rien ici')
})
it('renders empty slot when provided', () => {
const wrapper = mountComponent(
{ items: [], totalItems: 0 },
{ empty: '<p data-test="custom-empty">Vide</p>' },
)
expect(wrapper.find('[data-test="custom-empty"]').text()).toBe('Vide')
})
it('empty row has colspan equal to columns length', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
const td = wrapper.find('[data-test="empty-row"] td')
expect(td.attributes('colspan')).toBe('2')
})
})
describe('Row click', () => {
it('emits row-click with item on row click', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('emits row-click on Enter key', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.enter')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('emits row-click on Space key', async () => {
const wrapper = mountComponent()
await wrapper.findAll('[data-test="row"]')[0].trigger('keydown.space')
expect(wrapper.emitted('row-click')?.[0]).toEqual([{ nom: 'Dupont', ville: 'Paris' }])
})
it('rows have tabindex when clickable', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBe('0')
})
it('rows have cursor-pointer when clickable', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('[data-test="row"]')[0].classes()).toContain('cursor-pointer')
})
it('rows are not clickable when rowClickable is false', async () => {
const wrapper = mountComponent({ rowClickable: false })
await wrapper.findAll('[data-test="row"]')[0].trigger('click')
expect(wrapper.emitted('row-click')).toBeUndefined()
})
it('rows have no tabindex when not clickable', () => {
const wrapper = mountComponent({ rowClickable: false })
expect(wrapper.findAll('[data-test="row"]')[0].attributes('tabindex')).toBeUndefined()
})
})
describe('Accessibility', () => {
it('th elements have scope="col"', () => {
const wrapper = mountComponent()
const ths = wrapper.findAll('th')
ths.forEach(th => {
expect(th.attributes('scope')).toBe('col')
})
})
it('generates an id when not provided', () => {
const wrapper = mountComponent()
const id = wrapper.find('div').attributes('id')
expect(id).toMatch(/^malio-datatable-/)
})
it('uses custom id when provided', () => {
const wrapper = mountComponent({ id: 'my-table' })
expect(wrapper.find('div').attributes('id')).toBe('my-table')
})
})
describe('Pagination', () => {
it('hides pagination when totalItems is 0', () => {
const wrapper = mountComponent({ items: [], totalItems: 0 })
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(false)
})
it('shows pagination when totalItems > 0', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
})
it('renders all pages when totalPages <= 5', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
for (let i = 1; i <= 5; i++) {
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
}
})
it('highlights current page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
})
it('emits update:page on page button click', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
await wrapper.find('[data-test="page-3"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
})
it('Prev button is disabled on page 1', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
})
it('Next button is disabled on last page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 5 })
expect(wrapper.find('[data-test="next-button"]').attributes('disabled')).toBeDefined()
})
it('Prev button emits update:page with page - 1', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
await wrapper.find('[data-test="prev-button"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([2])
})
it('Next button emits update:page with page + 1', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
await wrapper.find('[data-test="next-button"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
})
it('shows ellipsis for truncated pages (> 5 pages)', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
expect(ellipsis.length).toBeGreaterThan(0)
expect(ellipsis[0].text()).toBe('…')
})
it('always shows first and last page when > 5 pages', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
})
it('shows 1 neighbor on each side of current page', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
})
it('pagination nav has aria-label', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
})
it('Prev button has aria-label "Page précédente"', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="prev-button"]').attributes('aria-label')).toBe('Page précédente')
})
it('Next button has aria-label "Page suivante"', () => {
const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="next-button"]').attributes('aria-label')).toBe('Page suivante')
})
})
describe('Per-page selector', () => {
it('emits update:per-page and reset page to 1 on change', async () => {
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
const select = wrapper.findComponent({ name: 'MalioSelect' })
select.vm.$emit('update:modelValue', 25)
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:per-page')?.[0]).toEqual([25])
expect(wrapper.emitted('update:page')?.[0]).toEqual([1])
})
})
})