ticket-create.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { getNode } from '@formkit/core'
  3. import { waitFor } from '@testing-library/vue'
  4. import { flushPromises } from '@vue/test-utils'
  5. import type { ExtendedRenderResult } from '#tests/support/components/index.ts'
  6. import { getTestRouter } from '#tests/support/components/renderComponent.ts'
  7. import { visitView } from '#tests/support/components/visitView.ts'
  8. import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
  9. import { mockGraphQLApi } from '#tests/support/mock-graphql-api.ts'
  10. import { mockPermissions } from '#tests/support/mock-permissions.ts'
  11. import { setupView } from '#tests/support/mock-user.ts'
  12. import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
  13. import { mockTicketOverviews } from '#tests/support/mocks/ticket-overviews.ts'
  14. import { nullableMock, waitUntil } from '#tests/support/utils.ts'
  15. import { AutocompleteSearchUserDocument } from '#shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/user.api.ts'
  16. import { FormUpdaterDocument } from '#shared/components/Form/graphql/queries/formUpdater.api.ts'
  17. import { ObjectManagerFrontendAttributesDocument } from '#shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.api.ts'
  18. import { defaultOrganization } from '#mobile/entities/organization/__tests__/mocks/organization-mocks.ts'
  19. import {
  20. ticketObjectAttributes,
  21. ticketArticleObjectAttributes,
  22. ticketPayload,
  23. } from '#mobile/entities/ticket/__tests__/mocks/ticket-mocks.ts'
  24. import { TicketCreateDocument } from '../graphql/mutations/create.api.ts'
  25. const visitTicketCreate = async (path = '/tickets/create') => {
  26. const mockObjectAttributes = mockGraphQLApi(
  27. ObjectManagerFrontendAttributesDocument,
  28. ).willBehave(({ object }) => {
  29. if (object === 'Ticket') {
  30. return {
  31. data: {
  32. objectManagerFrontendAttributes: ticketObjectAttributes(),
  33. },
  34. }
  35. }
  36. return {
  37. data: {
  38. objectManagerFrontendAttributes: ticketArticleObjectAttributes(),
  39. },
  40. }
  41. })
  42. const mockFormUpdater = mockGraphQLApi(FormUpdaterDocument).willResolve({
  43. formUpdater: {
  44. ticket_duplicate_detection: {
  45. show: true,
  46. hidden: false,
  47. value: { count: 0, items: [] },
  48. },
  49. group_id: {
  50. show: true,
  51. options: [
  52. {
  53. label: 'Users',
  54. value: 1,
  55. },
  56. ],
  57. clearable: true,
  58. },
  59. owner_id: {
  60. show: true,
  61. options: [{ value: 100, label: 'Max Mustermann' }],
  62. },
  63. priority_id: {
  64. show: true,
  65. options: [
  66. { value: 1, label: '1 low' },
  67. { value: 2, label: '2 normal' },
  68. { value: 3, label: '3 high' },
  69. ],
  70. clearable: true,
  71. },
  72. pending_time: {
  73. show: false,
  74. required: false,
  75. hidden: false,
  76. disabled: false,
  77. },
  78. state_id: {
  79. show: true,
  80. options: [
  81. { value: 4, label: 'closed' },
  82. { value: 2, label: 'open' },
  83. { value: 7, label: 'pending close' },
  84. { value: 3, label: 'pending reminder' },
  85. ],
  86. clearable: true,
  87. },
  88. },
  89. })
  90. const view = await visitView(path)
  91. await flushPromises()
  92. await getNode('ticket-create')?.settled
  93. return { mockFormUpdater, mockObjectAttributes, view }
  94. }
  95. const mockTicketCreate = () => {
  96. return mockGraphQLApi(TicketCreateDocument).willResolve({
  97. ticketCreate: {
  98. ticket: ticketPayload(),
  99. errors: null,
  100. __typename: 'TicketCreatePayload',
  101. },
  102. })
  103. }
  104. const mockCustomerQueryResult = () => {
  105. return mockGraphQLApi(AutocompleteSearchUserDocument).willResolve({
  106. autocompleteSearchUser: [
  107. nullableMock({
  108. value: '2',
  109. label: 'Nicole Braun',
  110. labelPlaceholder: null,
  111. heading: 'Zammad Foundation',
  112. headingPlaceholder: null,
  113. disabled: null,
  114. icon: null,
  115. user: {
  116. id: 'gid://zammad/User/2',
  117. internalId: 2,
  118. firstname: 'Nicole',
  119. lastname: 'Braun',
  120. fullname: 'Nicole Braun',
  121. image: null,
  122. objectAttributeValues: [],
  123. organization: {
  124. id: 'gid://zammad/Organization/1',
  125. internalId: 1,
  126. name: 'Zammad Foundation',
  127. active: true,
  128. objectAttributeValues: [],
  129. __typename: 'Organization',
  130. },
  131. hasSecondaryOrganizations: false,
  132. __typename: 'User',
  133. },
  134. __typename: 'AutocompleteSearchUserEntry',
  135. }),
  136. ],
  137. })
  138. }
  139. const nextStep = async (view: ExtendedRenderResult) => {
  140. await view.events.click(view.getByRole('button', { name: 'Continue' }))
  141. }
  142. const checkShownSteps = async (
  143. view: ExtendedRenderResult,
  144. steps: Array<string>,
  145. ) => {
  146. steps.forEach((step) => {
  147. expect(view.getByRole('button', { name: step })).toBeInTheDocument()
  148. })
  149. }
  150. beforeAll(async () => {
  151. // So we don't need to wait until it loads inside test.
  152. await import(
  153. '#shared/components/Form/fields/FieldEditor/FieldEditorInput.vue'
  154. )
  155. })
  156. describe('Creating new ticket as agent', () => {
  157. beforeEach(() => {
  158. mockPermissions(['ticket.agent'])
  159. mockApplicationConfig({
  160. customer_ticket_create: true,
  161. ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
  162. ui_ticket_create_default_type: 'phone-in',
  163. })
  164. })
  165. it('shows 4 steps for agents', async () => {
  166. const { view } = await visitTicketCreate()
  167. checkShownSteps(view, ['1', '2', '3', '4'])
  168. })
  169. it('disables the submit button if required data is missing', async () => {
  170. const { view } = await visitTicketCreate()
  171. expect(view.getByRole('button', { name: 'Create' })).toBeDisabled()
  172. })
  173. it('invalidates a single step if required data is missing', async () => {
  174. const { mockFormUpdater, view } = await visitTicketCreate()
  175. await view.events.type(view.getByLabelText('Title'), 'Foobar')
  176. await waitUntil(() => mockFormUpdater.calls.resolve)
  177. await nextStep(view)
  178. await nextStep(view)
  179. await nextStep(view)
  180. expect(
  181. view.getByRole('status', { name: 'Invalid values in step 3' }),
  182. ).toBeInTheDocument()
  183. })
  184. it.each([
  185. { name: 'Create', button: 'header submit button' },
  186. { name: 'Create ticket', button: 'footer submit button' },
  187. ])(
  188. 'redirects to detail view after successful ticket creation when clicked on $button',
  189. async ({ name }) => {
  190. const mockCustomer = mockCustomerQueryResult()
  191. const mockTicket = mockTicketCreate()
  192. const { mockFormUpdater, view } = await visitTicketCreate()
  193. await view.events.type(view.getByLabelText('Title'), 'Ticket Title')
  194. await waitUntil(() => mockFormUpdater.calls.resolve === 2)
  195. await nextStep(view)
  196. await nextStep(view)
  197. // Customer selection.
  198. await view.events.click(view.getByLabelText('Customer'))
  199. await view.events.type(await view.findByRole('searchbox'), 'nicole')
  200. await waitUntil(() => mockCustomer.calls.resolve)
  201. await view.events.click(view.getByText('Nicole Braun'))
  202. await waitUntil(() => mockFormUpdater.calls.resolve === 3)
  203. // Group selection.
  204. await view.events.click(view.getByLabelText('Group'))
  205. await view.events.click(view.getByText('Users'))
  206. await waitUntil(() => mockFormUpdater.calls.resolve === 4)
  207. await nextStep(view)
  208. // Text input.
  209. const editorNode = getNode('ticket-create')?.find('body', 'name')
  210. await editorNode?.input('Article body', false)
  211. // There is a button with "Create" in the header, and a "Create ticket" button in the footer.
  212. const submitButton = view.getByRole('button', { name })
  213. await waitUntil(() => !submitButton.hasAttribute('disabled'))
  214. expect(submitButton).not.toBeDisabled()
  215. // don't actually redirect
  216. const router = getTestRouter()
  217. router.mockMethods()
  218. await view.events.click(submitButton)
  219. await waitUntil(() => mockTicket.calls.resolve)
  220. await expect(view.findByRole('alert')).resolves.toHaveTextContent(
  221. 'Ticket has been created successfully.',
  222. )
  223. expect(router.replace).toHaveBeenCalledWith('/tickets/1')
  224. },
  225. )
  226. it('shows confirm popup, when leaving', async () => {
  227. const { view } = await visitTicketCreate()
  228. await view.events.type(view.getByLabelText('Title'), 'Foobar')
  229. // Wait on the changes
  230. await getNode('ticket-create')?.settled
  231. await view.events.click(view.getByRole('button', { name: 'Go home' }))
  232. expect(view.queryByTestId('popupWindow')).toBeInTheDocument()
  233. await expect(view.findByText('Confirm dialog')).resolves.toBeInTheDocument()
  234. })
  235. it('shows the CC field for type "Email"', async () => {
  236. const { mockFormUpdater, view } = await visitTicketCreate()
  237. await view.events.type(view.getByLabelText('Title'), 'Foobar')
  238. await waitUntil(() => mockFormUpdater.calls.resolve)
  239. await nextStep(view)
  240. await view.events.click(view.getByLabelText('Send Email'))
  241. await nextStep(view)
  242. expect(view.getByLabelText('CC')).toBeInTheDocument()
  243. })
  244. // The rest of the test cases are covered by E2E test, due to limitations of JSDOM test environment.
  245. })
  246. describe('Creating new ticket as customer', () => {
  247. beforeEach(() => {
  248. mockPermissions(['ticket.customer'])
  249. mockApplicationConfig({
  250. customer_ticket_create: true,
  251. })
  252. })
  253. it('shows 3 steps for customers', async () => {
  254. const { view } = await visitTicketCreate()
  255. checkShownSteps(view, ['1', '2', '3'])
  256. expect(view.queryByRole('button', { name: '4' })).not.toBeInTheDocument()
  257. })
  258. it('redirects to the error page if ticket creation is turned off', async () => {
  259. mockApplicationConfig({
  260. customer_ticket_create: false,
  261. })
  262. const { view } = await visitTicketCreate()
  263. expect(view.getByRole('main')).toHaveTextContent(
  264. 'Creating new tickets via web is disabled.',
  265. )
  266. })
  267. it('does not show the organization field without secondary organizations', async () => {
  268. mockUserCurrent({
  269. lastname: 'Doe',
  270. firstname: 'John',
  271. organization: defaultOrganization(),
  272. hasSecondaryOrganizations: false,
  273. })
  274. mockPermissions(['ticket.customer'])
  275. const { mockFormUpdater, view } = await visitTicketCreate()
  276. await view.events.type(view.getByLabelText('Title'), 'Foobar')
  277. await waitUntil(() => mockFormUpdater.calls.resolve)
  278. await nextStep(view)
  279. expect(view.queryByLabelText('Organization')).not.toBeInTheDocument()
  280. })
  281. it('does show the organization field with secondary organizations', async () => {
  282. mockUserCurrent({
  283. lastname: 'Doe',
  284. firstname: 'John',
  285. organization: defaultOrganization(),
  286. hasSecondaryOrganizations: true,
  287. })
  288. mockPermissions(['ticket.customer'])
  289. const { mockFormUpdater, view } = await visitTicketCreate()
  290. await view.events.type(view.getByLabelText('Title'), 'Foobar')
  291. await waitUntil(() => mockFormUpdater.calls.resolve)
  292. await nextStep(view)
  293. expect(view.queryByLabelText('Organization')).toBeInTheDocument()
  294. })
  295. it("doesn't show 'are you sure' dialog if duplicate protection is enabled and no changes were done", async () => {
  296. mockTicketOverviews()
  297. const { view } = await visitTicketCreate()
  298. await view.events.click(view.getByLabelText('Go home'))
  299. await waitFor(() => {
  300. expect(view.queryByText('Additional information')).not.toBeInTheDocument()
  301. })
  302. expect(view.queryByText('Confirm dialog')).not.toBeInTheDocument()
  303. })
  304. })
  305. describe('Creating new ticket as user having customer & agent permissions', () => {
  306. beforeEach(() => {
  307. mockPermissions(['ticket.agent', 'ticket.customer'])
  308. mockApplicationConfig({
  309. customer_ticket_create: true,
  310. ui_ticket_create_available_types: ['phone-in', 'phone-out', 'email-out'],
  311. ui_ticket_create_default_type: 'phone-in',
  312. })
  313. })
  314. it('does show the form for agents if having ticket.customer & ticket.agent permissions', async () => {
  315. const { view } = await visitTicketCreate()
  316. checkShownSteps(view, ['1', '2', '3', '4'])
  317. })
  318. })
  319. describe('Create ticket page redirects back', () => {
  320. it('correctly redirects from ticket create hash-based routes', async () => {
  321. setupView('agent')
  322. await visitTicketCreate('/#ticket/create')
  323. const router = getTestRouter()
  324. const route = router.currentRoute.value
  325. expect(route.name).toBe('TicketCreate')
  326. })
  327. it('correctly redirects from ticket create with id hash-based routes', async () => {
  328. setupView('agent')
  329. await visitTicketCreate('/#ticket/create/id/13214124')
  330. const router = getTestRouter()
  331. const route = router.currentRoute.value
  332. expect(route.name).toBe('TicketCreate')
  333. })
  334. })