TicketDetailView.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useLocalStorage } from '@vueuse/core'
  4. import {
  5. computed,
  6. toRef,
  7. provide,
  8. Teleport,
  9. markRaw,
  10. type Component,
  11. reactive,
  12. nextTick,
  13. watch,
  14. ref,
  15. } from 'vue'
  16. import {
  17. NotificationTypes,
  18. useNotifications,
  19. } from '#shared/components/CommonNotifications/index.ts'
  20. import Form from '#shared/components/Form/Form.vue'
  21. import type { FormSubmitData } from '#shared/components/Form/types.ts'
  22. import { useForm } from '#shared/components/Form/useForm.ts'
  23. import { setErrors } from '#shared/components/Form/utils.ts'
  24. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  25. import { useTicketArticleReplyAction } from '#shared/entities/ticket/composables/useTicketArticleReplyAction.ts'
  26. import { useTicketEdit } from '#shared/entities/ticket/composables/useTicketEdit.ts'
  27. import { useTicketEditForm } from '#shared/entities/ticket/composables/useTicketEditForm.ts'
  28. import type { TicketFormData } from '#shared/entities/ticket/types.ts'
  29. import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts'
  30. import {
  31. useArticleDataHandler,
  32. type AddArticleCallbackArgs,
  33. } from '#shared/entities/ticket-article/composables/useArticleDataHandler.ts'
  34. import UserError from '#shared/errors/UserError.ts'
  35. import { EnumTaskbarEntity, EnumFormUpdaterId } from '#shared/graphql/types.ts'
  36. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  37. import { useSessionStore } from '#shared/stores/session.ts'
  38. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  39. import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
  40. import LayoutContent from '#desktop/components/layout/LayoutContent.vue'
  41. import { useTaskbarTab } from '#desktop/entities/user/current/composables/useTaskbarTab.ts'
  42. import { useTaskbarTabStateUpdates } from '#desktop/entities/user/current/composables/useTaskbarTabStateUpdates.ts'
  43. import ArticleList from '../components/TicketDetailView/ArticleList.vue'
  44. import ArticleReply from '../components/TicketDetailView/ArticleReply.vue'
  45. import TicketDetailTopBar from '../components/TicketDetailView/TicketDetailTopBar/TicketDetailTopBar.vue'
  46. import TicketSidebar from '../components/TicketSidebar.vue'
  47. import { ARTICLES_INFORMATION_KEY } from '../composables/useArticleContext.ts'
  48. import { useTicketArticleReply } from '../composables/useTicketArticleReply.ts'
  49. import {
  50. initializeTicketInformation,
  51. provideTicketInformation,
  52. } from '../composables/useTicketInformation.ts'
  53. import {
  54. useTicketSidebar,
  55. useProvideTicketSidebar,
  56. } from '../composables/useTicketSidebar.ts'
  57. import {
  58. type TicketSidebarContext,
  59. TicketSidebarScreenType,
  60. } from '../types/sidebar.ts'
  61. interface Props {
  62. internalId: string
  63. }
  64. const props = defineProps<Props>()
  65. const { ticket, ticketId, canUpdateTicket, ...ticketInformation } =
  66. initializeTicketInformation(toRef(props, 'internalId'))
  67. const onAddArticleCallback = ({ articlesQuery }: AddArticleCallbackArgs) => {
  68. return (articlesQuery as QueryHandler).refetch()
  69. }
  70. const { articleResult, articlesQuery, isLoadingArticles } =
  71. useArticleDataHandler(ticketId, { pageSize: 20, onAddArticleCallback })
  72. provide(ARTICLES_INFORMATION_KEY, {
  73. articles: computed(() => articleResult.value),
  74. articlesQuery,
  75. })
  76. const {
  77. form,
  78. flags,
  79. isDisabled,
  80. isDirty,
  81. formNodeId,
  82. formReset,
  83. formSubmit,
  84. triggerFormUpdater,
  85. } = useForm()
  86. const {
  87. activeTaskbarTab,
  88. activeTaskbarTabFormId,
  89. activeTaskbarTabNewArticlePresent,
  90. } = useTaskbarTab(EnumTaskbarEntity.TicketZoom)
  91. const { setSkipNextStateUpdate } = useTaskbarTabStateUpdates(triggerFormUpdater)
  92. const sidebarContext = computed<TicketSidebarContext>(() => ({
  93. screenType: TicketSidebarScreenType.TicketDetailView,
  94. form: form.value,
  95. formValues: {
  96. // TODO: Workaround, to make the sidebars working for now.
  97. customer_id: ticket.value?.customer.internalId,
  98. organization_id: ticket.value?.organization?.internalId,
  99. },
  100. }))
  101. useProvideTicketSidebar(sidebarContext)
  102. const { hasSidebar, activeSidebar, switchSidebar } = useTicketSidebar()
  103. const {
  104. ticketSchema,
  105. articleSchema,
  106. currentArticleType,
  107. ticketArticleTypes,
  108. securityIntegration,
  109. isTicketCustomer,
  110. articleTypeHandler,
  111. articleTypeSelectHandler,
  112. } = useTicketEditForm(ticket, form)
  113. const formEditAttributeLocation = computed(() => {
  114. if (activeSidebar.value === 'information') return '#ticketEditAttributeForm'
  115. return '#wrapper-form-ticket-edit'
  116. })
  117. const {
  118. isArticleFormGroupValid,
  119. newTicketArticlePresent,
  120. showTicketArticleReplyForm,
  121. } = useTicketArticleReply(form, activeTaskbarTabNewArticlePresent)
  122. provideTicketInformation({
  123. ticket,
  124. ticketId,
  125. canUpdateTicket,
  126. form,
  127. newTicketArticlePresent,
  128. showTicketArticleReplyForm,
  129. ...ticketInformation,
  130. })
  131. const ticketEditSchemaData = reactive({
  132. formEditAttributeLocation,
  133. securityIntegration,
  134. newTicketArticlePresent,
  135. currentArticleType,
  136. })
  137. const ticketEditSchema = [
  138. {
  139. isLayout: true,
  140. component: 'Teleport',
  141. props: {
  142. to: '$formEditAttributeLocation',
  143. },
  144. children: [
  145. {
  146. isLayout: true,
  147. component: 'FormGroup',
  148. props: {
  149. class: '@container/form-group',
  150. showDirtyMark: true,
  151. },
  152. children: [ticketSchema],
  153. },
  154. ],
  155. },
  156. {
  157. if: '$newTicketArticlePresent',
  158. isLayout: true,
  159. component: 'Teleport',
  160. props: {
  161. to: '#ticketArticleReplyForm',
  162. },
  163. children: [
  164. {
  165. isLayout: true,
  166. component: 'FormGroup',
  167. props: {
  168. class: '@container/form-group',
  169. },
  170. children: [articleSchema],
  171. },
  172. ],
  173. },
  174. ]
  175. const { waitForConfirmation, waitForVariantConfirmation } = useConfirmation()
  176. const discardChanges = async () => {
  177. const confirm = await waitForVariantConfirmation('unsaved')
  178. if (confirm) {
  179. newTicketArticlePresent.value = false
  180. nextTick(() => {
  181. formReset()
  182. })
  183. }
  184. }
  185. const { isTicketFormGroupValid, initialTicketValue, editTicket } =
  186. useTicketEdit(ticket, form)
  187. const { openReplyForm } = useTicketArticleReplyAction(
  188. form,
  189. showTicketArticleReplyForm,
  190. )
  191. const isFormValid = computed(() => {
  192. if (!newTicketArticlePresent.value) return isTicketFormGroupValid.value
  193. return isTicketFormGroupValid.value && isArticleFormGroupValid.value
  194. })
  195. const formAdditionalRouteQueryParams = computed(() => ({
  196. taskbarId: activeTaskbarTab.value?.taskbarTabId,
  197. }))
  198. const { notify } = useNotifications()
  199. const checkSubmitEditTicket = () => {
  200. if (!isFormValid.value) {
  201. if (activeSidebar.value !== 'information') switchSidebar('information')
  202. if (newTicketArticlePresent.value && !isArticleFormGroupValid.value) {
  203. document
  204. .querySelector('#ticketArticleReplyForm')
  205. ?.scrollIntoView({ behavior: 'smooth' })
  206. }
  207. }
  208. formSubmit()
  209. }
  210. const skipValidator = ref<string>()
  211. const handleIncompleteChecklist = async (validator: string) => {
  212. const confirmed = await waitForConfirmation(
  213. __(
  214. 'You have unchecked items in the checklist. Do you want to handle them before closing this ticket?',
  215. ),
  216. {
  217. headerTitle: __('Incomplete Ticket Checklist'),
  218. headerIcon: 'checklist',
  219. buttonLabel: __('Yes, open the checklist'),
  220. cancelLabel: __('No, just close the ticket'),
  221. },
  222. )
  223. if (confirmed) {
  224. if (activeSidebar.value !== 'checklist') switchSidebar('checklist')
  225. return false
  226. }
  227. if (confirmed === false) {
  228. skipValidator.value = validator
  229. formSubmit()
  230. return true
  231. }
  232. return false
  233. }
  234. const handleUserErrorException = (exception: string) => {
  235. if (
  236. exception ===
  237. 'Service::Ticket::Update::Validator::ChecklistCompleted::IncompleteChecklistError'
  238. )
  239. return handleIncompleteChecklist(exception)
  240. }
  241. const submitEditTicket = async (formData: FormSubmitData<TicketFormData>) => {
  242. const updateFormData = currentArticleType.value?.updateForm
  243. if (updateFormData) {
  244. formData = updateFormData(formData)
  245. }
  246. return editTicket(formData, skipValidator.value)
  247. .then((result) => {
  248. if (result?.ticketUpdate?.ticket) {
  249. notify({
  250. id: 'ticket-update',
  251. type: NotificationTypes.Success,
  252. message: __('Ticket updated successfully.'),
  253. })
  254. newTicketArticlePresent.value = false
  255. return true // will reset the ticket form, because of the reset inside the Form component
  256. }
  257. return false
  258. })
  259. .catch((error) => {
  260. if (error instanceof UserError) {
  261. const exception = error.getFirstErrorException()
  262. if (exception) return handleUserErrorException(exception)
  263. if (form.value?.formNode) {
  264. setErrors(form.value.formNode, error)
  265. return
  266. }
  267. }
  268. notify({
  269. id: 'ticket-update-failed',
  270. type: NotificationTypes.Error,
  271. message: __('Ticket update failed.'),
  272. })
  273. })
  274. .finally(() => {
  275. skipValidator.value = undefined
  276. })
  277. }
  278. const handleShowArticleForm = (
  279. articleType: string,
  280. performReply: AppSpecificTicketArticleType['performReply'],
  281. ) => {
  282. openReplyForm({ articleType, ...performReply?.(ticket.value) })
  283. }
  284. const onEditFormSettled = () => {
  285. watch(
  286. () => flags.value.newArticlePresent,
  287. (newValue) => {
  288. newTicketArticlePresent.value = newValue
  289. },
  290. )
  291. }
  292. // Reset newTicketArticlePresent when ticket changed, that the
  293. // taskbar information is used for the start.
  294. watch(ticketId, () => {
  295. newTicketArticlePresent.value = undefined
  296. })
  297. const { userId } = useSessionStore()
  298. const articleReplyPinned = useLocalStorage(
  299. `${userId}-article-reply-pinned`,
  300. false,
  301. )
  302. </script>
  303. <template>
  304. <LayoutContent
  305. name="ticket-detail"
  306. no-padding
  307. background-variant="primary"
  308. :show-sidebar="hasSidebar"
  309. content-alignment="center"
  310. >
  311. <CommonLoader class="mt-8" :loading="!ticket">
  312. <div
  313. class="grid h-full w-full"
  314. :class="{
  315. 'grid-rows-[max-content_max-content_max-content]':
  316. !newTicketArticlePresent || !articleReplyPinned,
  317. 'grid-rows-[max-content_1fr_max-content]':
  318. newTicketArticlePresent && articleReplyPinned,
  319. }"
  320. >
  321. <TicketDetailTopBar />
  322. <ArticleList :aria-busy="isLoadingArticles" />
  323. <ArticleReply
  324. v-if="ticket?.id"
  325. :ticket="ticket"
  326. :new-article-present="newTicketArticlePresent"
  327. :create-article-type="ticket.createArticleType?.name"
  328. :ticket-article-types="ticketArticleTypes"
  329. :is-ticket-customer="isTicketCustomer"
  330. @show-article-form="handleShowArticleForm"
  331. />
  332. <div id="wrapper-form-ticket-edit" class="hidden" aria-hidden="true">
  333. <Form
  334. v-if="ticket?.id && initialTicketValue"
  335. id="form-ticket-edit"
  336. :key="ticketId"
  337. ref="form"
  338. :form-id="activeTaskbarTabFormId"
  339. :schema="ticketEditSchema"
  340. :flatten-form-groups="['ticket']"
  341. :handlers="[articleTypeHandler()]"
  342. :form-kit-plugins="[articleTypeSelectHandler]"
  343. :schema-data="ticketEditSchemaData"
  344. :initial-values="initialTicketValue"
  345. :initial-entity-object="ticket"
  346. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketEdit"
  347. :form-updater-additional-params="formAdditionalRouteQueryParams"
  348. use-object-attributes
  349. :schema-component-library="{
  350. Teleport: markRaw(Teleport) as unknown as Component,
  351. }"
  352. @submit="submitEditTicket($event as FormSubmitData<TicketFormData>)"
  353. @settled="onEditFormSettled"
  354. @changed="setSkipNextStateUpdate(true)"
  355. />
  356. </div>
  357. </div>
  358. </CommonLoader>
  359. <template #sideBar="{ isCollapsed, toggleCollapse }">
  360. <TicketSidebar
  361. :is-collapsed="isCollapsed"
  362. :toggle-collapse="toggleCollapse"
  363. :context="sidebarContext"
  364. />
  365. </template>
  366. <template #bottomBar>
  367. <CommonButton
  368. v-if="isDirty"
  369. size="large"
  370. variant="danger"
  371. :disabled="isDisabled"
  372. @click="discardChanges"
  373. >{{ __('Discard your unsaved changes') }}</CommonButton
  374. >
  375. <CommonButton
  376. size="large"
  377. variant="submit"
  378. type="button"
  379. :form="formNodeId"
  380. :disabled="isDisabled"
  381. @click="checkSubmitEditTicket"
  382. >{{ __('Update') }}</CommonButton
  383. >
  384. </template>
  385. </LayoutContent>
  386. </template>