TicketDetailArticlesView.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useEventListener } from '@vueuse/core'
  4. import { noop } from 'lodash-es'
  5. import { computed, watch, nextTick } from 'vue'
  6. import { useRoute } from 'vue-router'
  7. import { useStickyHeader } from '#shared/composables/useStickyHeader.ts'
  8. import type {
  9. PageInfo,
  10. TicketArticleUpdatesSubscription,
  11. TicketArticleUpdatesSubscriptionVariables,
  12. } from '#shared/graphql/types.ts'
  13. import {
  14. convertToGraphQLId,
  15. getIdFromGraphQLId,
  16. } from '#shared/graphql/utils.ts'
  17. import { getApolloClient } from '#shared/server/apollo/client.ts'
  18. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  19. import { useSessionStore } from '#shared/stores/session.ts'
  20. import { edgesToArray, waitForElement } from '#shared/utils/helpers.ts'
  21. import CommonLoader from '#mobile/components/CommonLoader/CommonLoader.vue'
  22. import { useHeader } from '#mobile/composables/useHeader.ts'
  23. import TicketArticlesList from '../components/TicketDetailView/ArticlesList.vue'
  24. import TicketHeader from '../components/TicketDetailView/TicketDetailViewHeader.vue'
  25. import TicketTitle from '../components/TicketDetailView/TicketDetailViewTitle.vue'
  26. import { useTicketArticlesQueryVariables } from '../composable/useTicketArticlesVariables.ts'
  27. import { useTicketInformation } from '../composable/useTicketInformation.ts'
  28. import { useTicketArticlesQuery } from '../graphql/queries/ticket/articles.api.ts'
  29. import { TicketArticleUpdatesDocument } from '../graphql/subscriptions/ticketArticlesUpdates.api.ts'
  30. interface Props {
  31. internalId: string
  32. }
  33. const props = defineProps<Props>()
  34. const ticketId = computed(() => convertToGraphQLId('Ticket', props.internalId))
  35. const {
  36. ticketArticlesMin,
  37. markTicketArticlesLoaded,
  38. getTicketArticlesQueryVariables,
  39. } = useTicketArticlesQueryVariables()
  40. const articlesQuery = new QueryHandler(
  41. useTicketArticlesQuery(() => getTicketArticlesQueryVariables(ticketId.value)),
  42. { errorShowNotification: false },
  43. )
  44. const result = articlesQuery.result()
  45. const allArticleLoaded = computed(() => {
  46. if (!result.value?.articles.totalCount) return false
  47. return result.value?.articles.edges.length < result.value?.articles.totalCount
  48. })
  49. const refetchArticlesQuery = (pageSize: Maybe<number>) => {
  50. articlesQuery.refetch({
  51. ticketId: ticketId.value,
  52. pageSize,
  53. })
  54. }
  55. const loadPreviousArticles = async () => {
  56. markTicketArticlesLoaded(ticketId.value)
  57. await articlesQuery.fetchMore({
  58. variables: {
  59. pageSize: null,
  60. loadDescription: false,
  61. beforeCursor: result.value?.articles.pageInfo.startCursor,
  62. },
  63. })
  64. }
  65. // When the last article is deleted, cursor has to be adjusted
  66. // to show newly created articles in the list (if any).
  67. // Cursor is offset-based, so the old cursor is pointing to an unavailable article,
  68. // thus using the cursor for the last article of the already filtered edges.
  69. const adjustPageInfoAfterDeletion = (nextEndCursorEdge?: Maybe<string>) => {
  70. const newPageInfo: Pick<PageInfo, 'startCursor' | 'endCursor'> = {}
  71. if (nextEndCursorEdge) {
  72. newPageInfo.endCursor = nextEndCursorEdge
  73. } else {
  74. newPageInfo.startCursor = null
  75. newPageInfo.endCursor = null
  76. }
  77. return newPageInfo
  78. }
  79. const {
  80. ticket,
  81. liveUserList,
  82. ticketQuery,
  83. scrolledToBottom,
  84. newArticlesIds,
  85. scrollDownState,
  86. } = useTicketInformation()
  87. const scrollElement = (element: Element) => {
  88. scrolledToBottom.value = true
  89. element.scrollIntoView({ behavior: 'smooth', block: 'start' })
  90. return true
  91. }
  92. const session = useSessionStore()
  93. const scheduleMyArticleScroll = async (
  94. articleInternalId: number,
  95. originalTime = new Date().getTime(),
  96. ): Promise<void> => {
  97. // try to scroll for 5 seconds
  98. const difference = new Date().getTime() - originalTime
  99. if (difference >= 5000 || typeof document === 'undefined') return
  100. const element = document.querySelector(
  101. `#article-${articleInternalId}`,
  102. ) as HTMLDivElement | null
  103. if (!element) {
  104. return new Promise((r) => requestAnimationFrame(r)).then(() =>
  105. scheduleMyArticleScroll(articleInternalId, originalTime),
  106. )
  107. }
  108. if (element.dataset.createdBy === session.userId) {
  109. element.scrollIntoView({ behavior: 'smooth', block: 'start' })
  110. }
  111. }
  112. const isAtTheBottom = () => {
  113. const scrollHeight =
  114. document.querySelector('main')?.scrollHeight || window.innerHeight
  115. const scrolledHeight = window.scrollY + window.innerHeight
  116. const scrollToBottom = scrollHeight - scrolledHeight
  117. return scrollToBottom < 20
  118. }
  119. const hasScroll = () => {
  120. const scrollHeight =
  121. document.querySelector('main')?.scrollHeight || window.innerHeight
  122. return scrollHeight > window.innerHeight
  123. }
  124. articlesQuery.subscribeToMore<
  125. TicketArticleUpdatesSubscriptionVariables,
  126. TicketArticleUpdatesSubscription
  127. >(() => ({
  128. document: TicketArticleUpdatesDocument,
  129. variables: {
  130. ticketId: ticketId.value,
  131. },
  132. onError: noop,
  133. updateQuery(previous, { subscriptionData }) {
  134. const updates = subscriptionData.data.ticketArticleUpdates
  135. if (!previous.articles || updates.updateArticle) return previous
  136. const previousArticlesEdges = previous.articles.edges
  137. const previousArticlesEdgesCount = previousArticlesEdges.length
  138. if (updates.removeArticleId) {
  139. const edges = previousArticlesEdges.filter(
  140. (edge) => edge.node.id !== updates.removeArticleId,
  141. )
  142. const removedArticleVisible = edges.length !== previousArticlesEdgesCount
  143. if (removedArticleVisible && !allArticleLoaded.value) {
  144. refetchArticlesQuery(ticketArticlesMin.value)
  145. return previous
  146. }
  147. const result = {
  148. ...previous,
  149. articles: {
  150. ...previous.articles,
  151. edges,
  152. totalCount: previous.articles.totalCount - 1,
  153. },
  154. }
  155. if (removedArticleVisible) {
  156. const nextEndCursorEdge =
  157. previousArticlesEdges[previousArticlesEdgesCount - 2]
  158. result.articles.pageInfo = {
  159. ...previous.articles.pageInfo,
  160. ...adjustPageInfoAfterDeletion(nextEndCursorEdge.cursor),
  161. }
  162. }
  163. // Trigger cache garbage collection after the returned article deletion subscription
  164. // updated the article list.
  165. nextTick(() => {
  166. getApolloClient().cache.gc()
  167. })
  168. return result
  169. }
  170. if (updates.addArticle) {
  171. scrollDownState.value = hasScroll()
  172. newArticlesIds.add(updates.addArticle.id)
  173. scheduleMyArticleScroll(getIdFromGraphQLId(updates.addArticle.id))
  174. const needRefetch =
  175. !previousArticlesEdges[previousArticlesEdgesCount - 1] ||
  176. updates.addArticle.createdAt <=
  177. previousArticlesEdges[previousArticlesEdgesCount - 1].node.createdAt
  178. if (!allArticleLoaded.value || needRefetch) {
  179. refetchArticlesQuery(null)
  180. } else {
  181. articlesQuery.fetchMore({
  182. variables: {
  183. pageSize: null,
  184. loadDescription: false,
  185. afterCursor: result.value?.articles.pageInfo.endCursor,
  186. },
  187. })
  188. }
  189. }
  190. return previous
  191. },
  192. }))
  193. const isLoadingTicket = computed(() => {
  194. return ticketQuery.loading().value && !ticket.value
  195. })
  196. const isRefetchingTicket = computed(
  197. () => ticketQuery.loading().value && !!ticket.value,
  198. )
  199. const totalCount = computed(() => result.value?.articles.totalCount || 0)
  200. const toMs = (date: string) => new Date(date).getTime()
  201. const articles = computed(() => {
  202. if (!result.value) {
  203. return []
  204. }
  205. const nodes = edgesToArray(result.value.articles)
  206. const totalCount = result.value.articles.totalCount || 0
  207. // description might've returned with "articles"
  208. const description = result.value.description?.edges[0]?.node
  209. if (totalCount > nodes.length && description) {
  210. nodes.unshift(description)
  211. }
  212. return nodes.sort((a, b) => toMs(a.createdAt) - toMs(b.createdAt))
  213. })
  214. useHeader({
  215. title: computed(() => {
  216. if (!ticket.value) return ''
  217. const { number, title } = ticket.value
  218. return `#${number} - ${title}`
  219. }),
  220. })
  221. // don't scroll only if articles are already loaded when you opened the page
  222. // and there is a saved scroll position
  223. // otherwise if we only check the scroll position we will never scroll to the bottom
  224. // because it can be defined when we open the page the first time
  225. if (result.value && window.history?.state?.scroll) {
  226. scrolledToBottom.value = true
  227. }
  228. const route = useRoute()
  229. let ignoreQuery = false
  230. // scroll to the article in the hash or to the last available article
  231. const initialScroll = async () => {
  232. if (route.hash) {
  233. const articleNode = document.querySelector(route.hash)
  234. if (articleNode) {
  235. return scrollElement(articleNode)
  236. }
  237. if (!articleNode && !ignoreQuery) {
  238. ignoreQuery = true
  239. await loadPreviousArticles()
  240. const node = document.querySelector(route.hash)
  241. if (node) {
  242. return scrollElement(node)
  243. }
  244. }
  245. }
  246. const internalId = articles.value[articles.value.length - 1]?.internalId
  247. if (!internalId) return false
  248. const lastArticle = await waitForElement(`#article-${internalId}`)
  249. if (!lastArticle) return false
  250. return scrollElement(lastArticle)
  251. }
  252. const stopScrollWatch = watch(
  253. () => articles.value.length,
  254. async () => {
  255. if (hasScroll() && !isAtTheBottom()) {
  256. scrollDownState.value = true
  257. }
  258. const scrolled = await initialScroll()
  259. if (scrolled) stopScrollWatch()
  260. },
  261. { immediate: true, flush: 'post' },
  262. )
  263. const { stickyStyles, headerElement } = useStickyHeader([
  264. isLoadingTicket,
  265. ticket,
  266. ])
  267. useEventListener(
  268. window.document,
  269. 'scroll',
  270. () => {
  271. scrollDownState.value = !isAtTheBottom()
  272. },
  273. { passive: true },
  274. )
  275. </script>
  276. <template>
  277. <div
  278. id="ticket-header"
  279. ref="headerElement"
  280. class="relative backdrop-blur-lg"
  281. :style="stickyStyles.header"
  282. >
  283. <TicketHeader
  284. :ticket="ticket"
  285. :live-user-list="liveUserList"
  286. :loading-ticket="isLoadingTicket"
  287. :refetching-ticket="isRefetchingTicket"
  288. />
  289. <CommonLoader
  290. :loading="isLoadingTicket"
  291. data-test-id="loader-title"
  292. class="flex border-b-[0.5px] border-white/10 bg-gray-600/90 px-4 py-5"
  293. >
  294. <TicketTitle v-if="ticket" :ticket="ticket" />
  295. </CommonLoader>
  296. </div>
  297. <div
  298. id="ticket-articles-list"
  299. class="flex flex-1 flex-col"
  300. :style="stickyStyles.body"
  301. >
  302. <CommonLoader
  303. data-test-id="loader-list"
  304. :loading="isLoadingTicket"
  305. class="mt-2"
  306. >
  307. <TicketArticlesList
  308. v-if="ticket"
  309. :ticket="ticket"
  310. :articles="articles"
  311. :total-count="totalCount"
  312. @load-previous="loadPreviousArticles"
  313. />
  314. </CommonLoader>
  315. </div>
  316. </template>