ticket-create.spec.ts 13 KB

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