CommonAdvancedTable.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
  2. import { faker } from '@faker-js/faker'
  3. import { waitFor, within } from '@testing-library/vue'
  4. import { vi } from 'vitest'
  5. import { ref } from 'vue'
  6. import ticketObjectAttributes from '#tests/graphql/factories/fixtures/ticket-object-attributes.ts'
  7. import {
  8. type ExtendedMountingOptions,
  9. renderComponent,
  10. } from '#tests/support/components/index.ts'
  11. import { mockRouterHooks } from '#tests/support/mock-vue-router.ts'
  12. import { waitForNextTick } from '#tests/support/utils.ts'
  13. import { mockObjectManagerFrontendAttributesQuery } from '#shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.mocks.ts'
  14. import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
  15. import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
  16. import {
  17. convertToGraphQLId,
  18. getIdFromGraphQLId,
  19. } from '#shared/graphql/utils.ts'
  20. import { i18n } from '#shared/i18n.ts'
  21. import type { ObjectWithId } from '#shared/types/utils.ts'
  22. import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
  23. import CommonAdvancedTable from '../CommonAdvancedTable.vue'
  24. import type { AdvancedTableProps, TableAdvancedItem } from '../types.ts'
  25. mockRouterHooks()
  26. const tableHeaders = ['title', 'owner', 'state', 'priority', 'created_at']
  27. const tableItems: TableAdvancedItem[] = [
  28. {
  29. id: convertToGraphQLId('Ticket', 1),
  30. title: 'Dummy ticket',
  31. owner: {
  32. __type: 'User',
  33. id: convertToGraphQLId('User', 1),
  34. internalId: 2,
  35. firstname: 'Agent 1',
  36. lastname: 'Test',
  37. fullname: 'Agent 1 Test',
  38. },
  39. state: {
  40. __typename: 'TicketState',
  41. id: convertToGraphQLId('TicketState', 1),
  42. name: 'open',
  43. },
  44. priority: {
  45. __typename: 'TicketPriority',
  46. id: convertToGraphQLId('TicketPriority', 3),
  47. name: '3 high',
  48. },
  49. created_at: '2021-01-01T12:00:00Z',
  50. },
  51. ]
  52. const tableActions: MenuItem[] = [
  53. {
  54. key: 'download',
  55. label: 'Download this row',
  56. icon: 'download',
  57. },
  58. {
  59. key: 'delete',
  60. label: 'Delete this row',
  61. icon: 'trash3',
  62. },
  63. ]
  64. vi.mock('@vueuse/core', async (importOriginal) => {
  65. const modules = await importOriginal<typeof import('@vueuse/core')>()
  66. return {
  67. ...modules,
  68. useInfiniteScroll: (
  69. scrollContainer: HTMLElement,
  70. callback: () => Promise<void>,
  71. ) => {
  72. callback()
  73. return { reset: vi.fn(), isLoading: ref(false) }
  74. },
  75. }
  76. })
  77. const renderTable = async (
  78. props: AdvancedTableProps,
  79. options: ExtendedMountingOptions<AdvancedTableProps> = { form: true },
  80. ) => {
  81. const wrapper = renderComponent(CommonAdvancedTable, {
  82. router: true,
  83. ...options,
  84. props: {
  85. object: EnumObjectManagerObjects.Ticket,
  86. ...props,
  87. },
  88. })
  89. await waitForNextTick()
  90. return wrapper
  91. }
  92. beforeEach(() => {
  93. mockObjectManagerFrontendAttributesQuery({
  94. objectManagerFrontendAttributes: ticketObjectAttributes(),
  95. })
  96. i18n.setTranslationMap(new Map([['Priority', 'Wichtigkeit']]))
  97. })
  98. describe('CommonAdvancedTable', () => {
  99. it('displays the table without actions', async () => {
  100. const wrapper = await renderTable({
  101. headers: tableHeaders,
  102. items: tableItems,
  103. totalItems: 100,
  104. caption: 'Table caption',
  105. })
  106. expect(wrapper.getByText('Title')).toBeInTheDocument()
  107. expect(wrapper.getByText('Owner')).toBeInTheDocument()
  108. expect(wrapper.getByText('Wichtigkeit')).toBeInTheDocument()
  109. expect(wrapper.getByText('State')).toBeInTheDocument()
  110. expect(wrapper.getByText('Dummy ticket')).toBeInTheDocument()
  111. expect(wrapper.getByText('Agent 1 Test')).toBeInTheDocument()
  112. expect(wrapper.getByText('open')).toBeInTheDocument()
  113. expect(wrapper.getByText('3 high')).toBeInTheDocument()
  114. expect(wrapper.queryByText('Actions')).toBeNull()
  115. })
  116. it('displays the table with actions', async () => {
  117. const wrapper = await renderTable(
  118. {
  119. headers: tableHeaders,
  120. items: tableItems,
  121. totalItems: 100,
  122. actions: tableActions,
  123. caption: 'Table caption',
  124. },
  125. {
  126. router: true,
  127. form: true,
  128. },
  129. )
  130. expect(wrapper.getByText('Actions')).toBeInTheDocument()
  131. expect(wrapper.getByLabelText('Action menu button')).toBeInTheDocument()
  132. })
  133. it('displays the additional data with the item suffix slot', async () => {
  134. const wrapper = await renderTable(
  135. {
  136. headers: tableHeaders,
  137. items: tableItems,
  138. totalItems: 100,
  139. actions: tableActions,
  140. caption: 'Table caption',
  141. },
  142. {
  143. router: true,
  144. form: true,
  145. slots: {
  146. 'item-suffix-title': '<span>Additional Example</span>',
  147. },
  148. },
  149. )
  150. expect(wrapper.getByText('Additional Example')).toBeInTheDocument()
  151. })
  152. it('generates expected DOM', async () => {
  153. // TODO: check if such snapshot test is really the way we want to go.
  154. const view = await renderTable(
  155. {
  156. headers: tableHeaders,
  157. items: tableItems,
  158. totalItems: 100,
  159. actions: tableActions,
  160. caption: 'Table caption',
  161. },
  162. // NB: Please don't remove this, otherwise snapshot would contain markup of many more components other than the
  163. // one under the test, which can lead to false positives.
  164. {
  165. shallow: true,
  166. form: true,
  167. },
  168. )
  169. expect(view.baseElement.querySelector('table')).toMatchFileSnapshot(
  170. `${__filename}.snapshot.txt`,
  171. )
  172. })
  173. it('supports text truncation in cell content', async () => {
  174. const wrapper = await renderTable({
  175. headers: [...tableHeaders, 'truncated', 'untruncated'],
  176. attributes: [
  177. {
  178. name: 'truncated',
  179. label: 'Truncated',
  180. headerPreferences: {
  181. truncate: true,
  182. },
  183. columnPreferences: {},
  184. dataOption: {
  185. type: 'text',
  186. },
  187. dataType: 'input',
  188. },
  189. {
  190. name: 'untruncated',
  191. label: 'Untruncated',
  192. headerPreferences: {
  193. truncate: false,
  194. },
  195. columnPreferences: {},
  196. dataOption: {
  197. type: 'text',
  198. },
  199. dataType: 'input',
  200. },
  201. ],
  202. items: [
  203. ...tableItems,
  204. {
  205. id: convertToGraphQLId('Ticket', 2),
  206. name: 'Max Mustermann',
  207. role: 'Admin',
  208. truncated: 'Some text to be truncated',
  209. untruncated: 'Some text not to be truncated',
  210. },
  211. ],
  212. totalItems: 100,
  213. caption: 'Table caption',
  214. })
  215. const truncatedText = wrapper.getByText('Some text to be truncated')
  216. expect(truncatedText).toHaveAttribute('data-tooltip', 'true')
  217. expect(truncatedText.parentElement).toHaveClass('truncate')
  218. const untruncatedText = wrapper.getByText('Some text not to be truncated')
  219. expect(untruncatedText).not.toHaveAttribute('data-tooltip')
  220. expect(untruncatedText.parentElement).not.toHaveClass('truncate')
  221. })
  222. it('supports header slot', async () => {
  223. const wrapper = await renderTable(
  224. {
  225. headers: tableHeaders,
  226. items: tableItems,
  227. actions: tableActions,
  228. totalItems: 100,
  229. caption: 'Table caption',
  230. },
  231. {
  232. form: true,
  233. slots: {
  234. 'column-header-title': '<div>Custom header</div>',
  235. },
  236. },
  237. )
  238. expect(wrapper.getByText('Custom header')).toBeInTheDocument()
  239. })
  240. it('supports listening for row click events', async () => {
  241. const mockedCallback = vi.fn()
  242. const item = tableItems[0]
  243. const wrapper = renderComponent(
  244. {
  245. components: { CommonAdvancedTable },
  246. setup() {
  247. return {
  248. mockedCallback,
  249. tableHeaders,
  250. attributes: [
  251. {
  252. name: 'title',
  253. label: 'Title',
  254. headerPreferences: {},
  255. columnPreferences: {},
  256. dataOption: {},
  257. dataType: 'input',
  258. },
  259. ],
  260. items: [item],
  261. }
  262. },
  263. template: `
  264. <CommonAdvancedTable @click-row="mockedCallback" :headers="tableHeaders" :attributes="attributes"
  265. :items="items" :total-items="100" caption="Table caption" />`,
  266. },
  267. { form: true },
  268. )
  269. await waitForNextTick()
  270. await wrapper.events.click(wrapper.getByText('Dummy ticket'))
  271. expect(mockedCallback).toHaveBeenCalledWith(item)
  272. mockedCallback.mockClear()
  273. wrapper.getByRole('row', { description: 'Select table row' }).focus()
  274. await wrapper.events.keyboard('{enter}')
  275. expect(mockedCallback).toHaveBeenCalledWith(item)
  276. })
  277. it('supports marking row in active color', async () => {
  278. const wrapper = await renderTable({
  279. headers: tableHeaders,
  280. selectedRowId: '2',
  281. items: [
  282. {
  283. id: '2',
  284. name: 'foo',
  285. },
  286. ],
  287. totalItems: 100,
  288. caption: 'Table caption',
  289. })
  290. const row = wrapper.getByTestId('table-row')
  291. expect(row).toHaveClass('!bg-blue-800')
  292. expect(within(row).getAllByRole('cell')[1].children[0]).toHaveClass(
  293. 'text-black dark:text-white',
  294. )
  295. })
  296. it('supports adding class to table header', async () => {
  297. const wrapper = await renderTable({
  298. headers: ['name'],
  299. attributes: [
  300. {
  301. name: 'name',
  302. label: 'Awesome Cell Header',
  303. headerPreferences: {
  304. labelClass: 'text-red-500 font-bold',
  305. },
  306. columnPreferences: {},
  307. dataOption: {
  308. type: 'text',
  309. },
  310. dataType: 'input',
  311. },
  312. ],
  313. items: [],
  314. totalItems: 100,
  315. caption: 'Table caption',
  316. })
  317. expect(wrapper.getByText('Awesome Cell Header')).toHaveClass(
  318. 'text-red-500 font-bold',
  319. )
  320. })
  321. it('supports adding a link to a cell', async () => {
  322. const wrapper = await renderTable(
  323. {
  324. headers: ['title'],
  325. attributeExtensions: {
  326. title: {
  327. columnPreferences: {
  328. link: {
  329. internal: true,
  330. getLink: (item: ObjectWithId) =>
  331. `/tickets/${getIdFromGraphQLId(item.id)}`,
  332. },
  333. },
  334. },
  335. },
  336. items: [tableItems[0]],
  337. totalItems: 100,
  338. caption: 'Table caption',
  339. },
  340. {
  341. form: true,
  342. router: true,
  343. },
  344. )
  345. const linkCell = wrapper.getByRole('link')
  346. expect(linkCell).toHaveTextContent('Dummy ticket')
  347. expect(linkCell).toHaveAttribute('href', '/desktop/tickets/1')
  348. expect(linkCell).not.toHaveAttribute('target')
  349. })
  350. it.todo('supports row selection', async () => {
  351. const checkedRows = ref([])
  352. const items = [
  353. {
  354. id: convertToGraphQLId('Ticket', 1),
  355. label: 'selection data 1',
  356. },
  357. {
  358. id: convertToGraphQLId('Ticket', 2),
  359. label: 'selection data 2',
  360. },
  361. ]
  362. const wrapper = await renderTable(
  363. {
  364. headers: ['label'],
  365. items,
  366. hasCheckboxColumn: true,
  367. totalItems: 100,
  368. caption: 'Table caption',
  369. },
  370. { form: true, vModel: { checkedRows } },
  371. )
  372. expect(wrapper.getAllByRole('checkbox')).toHaveLength(3)
  373. const selectAllCheckbox = wrapper.getByLabelText('Select all entries')
  374. expect(selectAllCheckbox).not.toHaveAttribute('checked')
  375. const rowCheckboxes = wrapper.getAllByRole('checkbox', {
  376. name: 'Select this entry',
  377. })
  378. await wrapper.events.click(rowCheckboxes[0])
  379. expect(rowCheckboxes[0]).toHaveAttribute('checked')
  380. await wrapper.events.click(rowCheckboxes[1])
  381. await waitFor(() => expect(checkedRows.value).toEqual(items))
  382. await waitFor(() => expect(selectAllCheckbox).toHaveAttribute('checked'))
  383. await wrapper.events.click(wrapper.getByLabelText('Deselect all entries'))
  384. await waitFor(() => expect(rowCheckboxes[0]).not.toHaveAttribute('checked'))
  385. expect(rowCheckboxes[1]).not.toHaveAttribute('checked')
  386. await wrapper.events.click(rowCheckboxes[1])
  387. expect(
  388. await wrapper.findByLabelText('Deselect this entry'),
  389. ).toBeInTheDocument()
  390. })
  391. it.todo('supports disabling checkbox item for specific rows', async () => {
  392. const checkedRows = ref([])
  393. const items = [
  394. {
  395. id: convertToGraphQLId('Ticket', 1),
  396. checked: false,
  397. disabled: true,
  398. label: 'selection data 1',
  399. },
  400. {
  401. id: convertToGraphQLId('Ticket', 2),
  402. checked: true,
  403. disabled: true,
  404. label: 'selection data 2',
  405. },
  406. ]
  407. const wrapper = await renderTable(
  408. {
  409. headers: ['label'],
  410. items,
  411. hasCheckboxColumn: true,
  412. totalItems: 100,
  413. caption: 'Table caption',
  414. },
  415. { form: true, vModel: { checkedRows } },
  416. )
  417. const checkboxes = wrapper.getAllByRole('checkbox')
  418. expect(checkboxes).toHaveLength(3)
  419. expect(checkboxes[1]).toBeDisabled()
  420. expect(checkboxes[1]).not.toBeChecked()
  421. expect(checkboxes[2]).toHaveAttribute('value', 'true')
  422. await wrapper.events.click(checkboxes[1])
  423. expect(checkedRows.value).toEqual([])
  424. await wrapper.events.click(checkboxes[0])
  425. expect(checkedRows.value).toEqual([])
  426. })
  427. it('supports sorting', async () => {
  428. mockObjectManagerFrontendAttributesQuery({
  429. objectManagerFrontendAttributes: ticketObjectAttributes(),
  430. })
  431. const items = [
  432. {
  433. id: convertToGraphQLId('Ticket', 1),
  434. checked: false,
  435. disabled: false,
  436. title: 'selection data 1',
  437. },
  438. ]
  439. const wrapper = await renderTable({
  440. headers: ['title'],
  441. items,
  442. hasCheckboxColumn: true,
  443. totalItems: 100,
  444. caption: 'Table caption',
  445. orderBy: 'label',
  446. })
  447. const sortButton = await wrapper.findByRole('button', {
  448. name: 'Sorted descending',
  449. })
  450. await wrapper.events.click(sortButton)
  451. expect(wrapper.emitted('sort').at(-1)).toEqual(['title', 'ASCENDING'])
  452. })
  453. it('informs the user about reached limits', async () => {
  454. const items = Array.from({ length: 30 }, () => ({
  455. id: convertToGraphQLId('Ticket', faker.number.int()),
  456. checked: false,
  457. disabled: false,
  458. title: faker.word.words(),
  459. }))
  460. const scrollContainer = document.createElement('div')
  461. document.body.appendChild(scrollContainer)
  462. const wrapper = await renderTable({
  463. headers: ['title'],
  464. items,
  465. hasCheckboxColumn: true,
  466. totalItems: 30,
  467. maxItems: 20,
  468. scrollContainer,
  469. caption: 'Table caption',
  470. orderBy: 'label',
  471. })
  472. expect(
  473. wrapper.getByText(
  474. 'You reached the table limit of 20 tickets (10 remaining).',
  475. ),
  476. ).toBeInTheDocument()
  477. scrollContainer.remove()
  478. })
  479. it('informs the user about table end', async () => {
  480. const items = Array.from({ length: 30 }, () => ({
  481. id: convertToGraphQLId('Ticket', faker.number.int()),
  482. checked: false,
  483. disabled: false,
  484. title: faker.word.sample(),
  485. }))
  486. const scrollContainer = document.createElement('div')
  487. document.body.appendChild(scrollContainer)
  488. const wrapper = await renderTable({
  489. headers: ['title'],
  490. items,
  491. hasCheckboxColumn: true,
  492. totalItems: 30,
  493. maxItems: 30,
  494. scrollContainer,
  495. caption: 'Table caption',
  496. orderBy: 'label',
  497. })
  498. expect(
  499. wrapper.getByText("You don't have more tickets to load."),
  500. ).toBeInTheDocument()
  501. scrollContainer.remove()
  502. })
  503. it('supports grouping and shows incomplete count', async () => {
  504. mockObjectManagerFrontendAttributesQuery({
  505. objectManagerFrontendAttributes: ticketObjectAttributes(),
  506. })
  507. const items = [
  508. createDummyTicket(),
  509. createDummyTicket({ ticketId: '2', title: faker.word.sample() }),
  510. ]
  511. const wrapper = await renderTable({
  512. headers: [
  513. 'priorityIcon',
  514. 'stateIcon',
  515. 'title',
  516. 'customer',
  517. 'organization',
  518. 'group',
  519. 'owner',
  520. 'state',
  521. 'created_at',
  522. ],
  523. items,
  524. hasCheckboxColumn: true,
  525. totalItems: 30,
  526. maxItems: 30,
  527. groupBy: 'customer',
  528. caption: 'Table caption',
  529. })
  530. expect(
  531. wrapper.getByRole('row', { name: 'Nicole Braun 2+' }),
  532. ).toBeInTheDocument()
  533. })
  534. })