TicketDetailView.vue 11 KB

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