TicketDetailArticlesView.vue 8.4 KB

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