TicketDetailView.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { noop } from 'lodash-es'
  4. import { computed, provide, ref, reactive, toRef } from 'vue'
  5. import {
  6. onBeforeRouteLeave,
  7. onBeforeRouteUpdate,
  8. RouterView,
  9. useRoute,
  10. useRouter,
  11. } from 'vue-router'
  12. import {
  13. NotificationTypes,
  14. useNotifications,
  15. } from '#shared/components/CommonNotifications/index.ts'
  16. import Form from '#shared/components/Form/Form.vue'
  17. import type { FormSubmitData } from '#shared/components/Form/types.ts'
  18. import { useForm } from '#shared/components/Form/useForm.ts'
  19. import { useConfirmation } from '#shared/composables/useConfirmation.ts'
  20. import { useOnlineNotificationSeen } from '#shared/composables/useOnlineNotificationSeen.ts'
  21. import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts'
  22. import { TicketUpdatesDocument } from '#shared/entities/ticket/graphql/subscriptions/ticketUpdates.api.ts'
  23. import { useErrorHandler } from '#shared/errors/useErrorHandler.ts'
  24. import UserError from '#shared/errors/UserError.ts'
  25. import type {
  26. TicketUpdatesSubscription,
  27. TicketUpdatesSubscriptionVariables,
  28. } from '#shared/graphql/types.ts'
  29. import { EnumFormUpdaterId } from '#shared/graphql/types.ts'
  30. import { convertToGraphQLId } from '#shared/graphql/utils.ts'
  31. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  32. import { useApplicationStore } from '#shared/stores/application.ts'
  33. import CommonLoader from '#mobile/components/CommonLoader/CommonLoader.vue'
  34. import { useCommonSelect } from '#mobile/components/CommonSelect/useCommonSelect.ts'
  35. import { getOpenedDialogs } from '#mobile/composables/useDialog.ts'
  36. import { useTicketWithMentionLimitQuery } from '#mobile/entities/ticket/graphql/queries/ticketWithMentionLimit.api.ts'
  37. import type { TicketInformation } from '#mobile/entities/ticket/types.ts'
  38. import TicketDetailViewActions from '../components/TicketDetailView/TicketDetailViewActions.vue'
  39. import { useTicketArticleReply } from '../composable/useTicketArticleReply.ts'
  40. import { useTicketEdit } from '../composable/useTicketEdit.ts'
  41. import { useTicketEditForm } from '../composable/useTicketEditForm.ts'
  42. import { TICKET_INFORMATION_SYMBOL } from '../composable/useTicketInformation.ts'
  43. import { useTicketLiveUser } from '../composable/useTicketLiveUser.ts'
  44. interface Props {
  45. internalId: string
  46. }
  47. const props = defineProps<Props>()
  48. const ticketId = computed(() => convertToGraphQLId('Ticket', props.internalId))
  49. const MENTIONS_LIMIT = 5
  50. const { createQueryErrorHandler } = useErrorHandler()
  51. const ticketQuery = new QueryHandler(
  52. useTicketWithMentionLimitQuery(() => ({
  53. ticketId: ticketId.value,
  54. mentionsCount: MENTIONS_LIMIT,
  55. })),
  56. {
  57. errorCallback: createQueryErrorHandler({
  58. notFound: __(
  59. 'Ticket with specified ID was not found. Try checking the URL for errors.',
  60. ),
  61. forbidden: __('You have insufficient rights to view this ticket.'),
  62. }),
  63. },
  64. )
  65. const ticketResult = ticketQuery.result()
  66. const ticket = computed(() => ticketResult.value?.ticket)
  67. ticketQuery.subscribeToMore<
  68. TicketUpdatesSubscriptionVariables,
  69. TicketUpdatesSubscription
  70. >(() => ({
  71. document: TicketUpdatesDocument,
  72. variables: {
  73. ticketId: ticketId.value,
  74. },
  75. onError: noop,
  76. }))
  77. const formLocation = ref('body')
  78. const formVisible = computed(() => formLocation.value !== 'body')
  79. const { form, canSubmit, isDirty, formSubmit, formReset } = useForm()
  80. const { initialTicketValue, isTicketFormGroupValid, editTicket } =
  81. useTicketEdit(ticket, form)
  82. const canUpdateTicket = computed(() => !!ticket.value?.policy.update)
  83. const needSpaceForSaveBanner = computed(() => {
  84. return canUpdateTicket.value && isDirty.value
  85. })
  86. const {
  87. articleReplyDialog,
  88. newTicketArticleRequested,
  89. newTicketArticlePresent,
  90. isArticleFormGroupValid,
  91. openArticleReplyDialog,
  92. closeArticleReplyDialog,
  93. } = useTicketArticleReply(ticket, form, needSpaceForSaveBanner)
  94. const {
  95. currentArticleType,
  96. ticketEditSchema,
  97. articleTypeHandler,
  98. articleTypeSelectHandler,
  99. } = useTicketEditForm(ticket, form)
  100. const { isTicketAgent } = useTicketView(ticket)
  101. const { notify } = useNotifications()
  102. const saveTicketForm = async (formData: FormSubmitData) => {
  103. const updateFormData = currentArticleType.value?.updateForm
  104. if (updateFormData) {
  105. formData = updateFormData(formData)
  106. }
  107. try {
  108. const result = await editTicket(formData)
  109. if (result?.ticketUpdate?.ticket) {
  110. notify({
  111. id: 'ticket-update',
  112. type: NotificationTypes.Success,
  113. message: __('Ticket updated successfully.'),
  114. })
  115. // Reset article form after ticket update and reset form.
  116. return () => {
  117. newTicketArticlePresent.value = false
  118. closeArticleReplyDialog().then(() => {
  119. // after the dialog is closed, form changes value from reseted { ticket, article } to { ticket }
  120. // which makes it dirty, so we reset it again to be just { ticket }
  121. formReset({ ticket: formData.ticket })
  122. })
  123. }
  124. }
  125. } catch (errors) {
  126. if (errors instanceof UserError) {
  127. notify({
  128. id: 'ticket-update-error',
  129. message: errors.generalErrors[0],
  130. type: NotificationTypes.Error,
  131. })
  132. }
  133. }
  134. }
  135. const updateFormLocation = (newLocation: string) => {
  136. formLocation.value = newLocation
  137. }
  138. const isFormValid = computed(() => {
  139. if (!newTicketArticlePresent.value) return isTicketFormGroupValid.value
  140. return isTicketFormGroupValid.value && isArticleFormGroupValid.value
  141. })
  142. const showArticleReplyDialog = () => {
  143. return openArticleReplyDialog({ updateFormLocation })
  144. }
  145. const { liveUserList } = useTicketLiveUser(
  146. toRef(() => props.internalId),
  147. isTicketAgent,
  148. isDirty,
  149. )
  150. const refetchingStatus = ref(false)
  151. const updateRefetchingStatus = (status: boolean) => {
  152. refetchingStatus.value = status
  153. }
  154. const scrolledToBottom = ref(false)
  155. const scrollDownState = ref(false)
  156. onBeforeRouteUpdate((to, from) => {
  157. // reset if we opened another ticket from the same page (via ticket merge, for example)
  158. if (to.params.internalId !== from.params.internalId) {
  159. scrolledToBottom.value = false
  160. }
  161. scrollDownState.value = false
  162. })
  163. const newArticlesIds = reactive(new Set<string>())
  164. provide<TicketInformation>(TICKET_INFORMATION_SYMBOL, {
  165. ticketQuery,
  166. initialFormTicketValue: initialTicketValue,
  167. ticket,
  168. form,
  169. scrolledToBottom,
  170. newTicketArticleRequested,
  171. newTicketArticlePresent,
  172. updateFormLocation,
  173. canUpdateTicket,
  174. showArticleReplyDialog,
  175. liveUserList,
  176. refetchingStatus,
  177. newArticlesIds,
  178. scrollDownState,
  179. updateRefetchingStatus,
  180. })
  181. useOnlineNotificationSeen(ticket)
  182. onBeforeRouteLeave(async () => {
  183. if (!isDirty.value) return true
  184. const { waitForConfirmation } = useConfirmation()
  185. const confirmed = await waitForConfirmation(
  186. __('Are you sure? You have unsaved changes that will get lost.'),
  187. {
  188. buttonLabel: __('Discard changes'),
  189. buttonVariant: 'danger',
  190. },
  191. )
  192. return confirmed
  193. })
  194. const router = useRouter()
  195. const route = useRoute()
  196. const submitForm = () => {
  197. if (!isTicketFormGroupValid.value && route.name !== 'Edit') {
  198. if (articleReplyDialog.isOpened.value) {
  199. closeArticleReplyDialog(true)
  200. }
  201. router.push(`/tickets/${ticket.value?.internalId}/information`)
  202. } else if (
  203. newTicketArticlePresent.value &&
  204. !isArticleFormGroupValid.value &&
  205. !articleReplyDialog.isOpened.value
  206. ) {
  207. showArticleReplyDialog()
  208. }
  209. formSubmit()
  210. }
  211. const application = useApplicationStore()
  212. const securityIntegration = computed<boolean>(
  213. () =>
  214. (application.config.smime_integration ||
  215. application.config.pgp_integration) ??
  216. false,
  217. )
  218. const ticketEditSchemaData = reactive({
  219. formLocation,
  220. securityIntegration,
  221. newTicketArticleRequested,
  222. newTicketArticlePresent,
  223. currentArticleType,
  224. })
  225. const { isOpened: commonSelectOpened } = useCommonSelect()
  226. const showReplyButton = computed(() => {
  227. if (articleReplyDialog.isOpened.value) return false
  228. return canUpdateTicket.value
  229. })
  230. const showScrollDown = computed(() => {
  231. if (articleReplyDialog.isOpened.value) return false
  232. return scrollDownState.value
  233. })
  234. // show banner only in "articles list", "ticket information" and "create article" views
  235. const showBottomBanner = computed(() => {
  236. const dialogs = getOpenedDialogs()
  237. if (
  238. commonSelectOpened.value ||
  239. dialogs.size > 1 ||
  240. (dialogs.size === 1 && !articleReplyDialog.isOpened.value)
  241. )
  242. return false
  243. return (
  244. (canUpdateTicket.value && isDirty.value) ||
  245. showReplyButton.value ||
  246. showScrollDown.value
  247. )
  248. })
  249. </script>
  250. <template>
  251. <RouterView />
  252. <div class="pb-safe-16"></div>
  253. <!-- submit form is always present in the DOM, so we can access FormKit validity state -->
  254. <!-- if it's visible, it's moved to the [data-ticket-edit-form] element, which is in TicketInformationDetail -->
  255. <Teleport v-if="canUpdateTicket" :to="formLocation">
  256. <CommonLoader
  257. :class="formVisible ? 'visible' : 'hidden'"
  258. :loading="!ticket"
  259. >
  260. <Form
  261. v-if="ticket?.id && initialTicketValue"
  262. id="form-ticket-edit"
  263. :key="ticket.id"
  264. ref="form"
  265. :schema="ticketEditSchema"
  266. :flatten-form-groups="['ticket']"
  267. :handlers="[articleTypeHandler()]"
  268. :form-kit-plugins="[articleTypeSelectHandler]"
  269. :schema-data="ticketEditSchemaData"
  270. :initial-values="initialTicketValue"
  271. :initial-entity-object="ticket"
  272. :form-updater-id="EnumFormUpdaterId.FormUpdaterUpdaterTicketEdit"
  273. use-object-attributes
  274. :aria-hidden="!formVisible"
  275. :class="formVisible ? 'visible' : 'hidden'"
  276. @submit="saveTicketForm($event as FormSubmitData)"
  277. />
  278. </CommonLoader>
  279. </Teleport>
  280. <Teleport v-if="form?.formNode" to="body">
  281. <TicketDetailViewActions
  282. :form-invalid="canSubmit && !isFormValid"
  283. :new-replies-count="newArticlesIds.size"
  284. :new-article-present="newTicketArticlePresent"
  285. :can-reply="showReplyButton"
  286. :can-save="canUpdateTicket && isDirty"
  287. :can-scroll-down="showScrollDown"
  288. :hidden="!showBottomBanner"
  289. @reply="showArticleReplyDialog"
  290. @save="submitForm"
  291. />
  292. </Teleport>
  293. </template>