Browse Source

Maintenance: Desktop view - Article see more is not working correctly when...

Benjamin Scharf 2 months ago
parent
commit
91d1f2a246

+ 1 - 1
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleBubble/ArticleBubbleBody.vue

@@ -107,7 +107,7 @@ onMounted(() => {
       :class="{
         BubbleGradient: hasShowMore && !shownMore,
       }"
-    ></div>
+    />
     <CommonButton
       v-if="hasShowMore"
       class="!p-0 !outline-transparent"

+ 3 - 0
app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/ArticleBubble.spec.ts

@@ -1,6 +1,7 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
 import { getByAltText, queryByAltText } from '@testing-library/vue'
+import { flushPromises } from '@vue/test-utils'
 
 import { renderComponent } from '#tests/support/components/index.ts'
 import { getTestRouter } from '#tests/support/components/renderComponent.ts'
@@ -151,6 +152,7 @@ describe('component for displaying text article', () => {
     const seeMoreButton = view.getByText('See more')
     expect(seeMoreButton).toBeInTheDocument()
 
+    await flushPromises()
     expect(content, 'has maximum height').toHaveStyle({ height: '320px' })
 
     await view.events.click(seeMoreButton)
@@ -186,6 +188,7 @@ describe('component for displaying text article', () => {
     const seeMoreButton = view.getByText('See more')
     expect(seeMoreButton).toBeInTheDocument()
 
+    await flushPromises()
     expect(content, 'has maximum height').toHaveStyle({ height: '65px' })
 
     await view.events.click(seeMoreButton)

+ 5 - 0
app/frontend/shared/composables/useArticleToggleMore.ts

@@ -2,6 +2,7 @@
 
 import { onMounted, ref } from 'vue'
 
+import { waitForImagesToLoad } from '#shared/utils/dom.ts'
 import { waitForAnimationFrame } from '#shared/utils/helpers.ts'
 
 export const useArticleToggleMore = () => {
@@ -59,6 +60,10 @@ export const useArticleToggleMore = () => {
   onMounted(async () => {
     if (!bubbleElement.value) return
 
+    // Wait for inline images to load before calculating height
+    // Resolved immediately if no images are present
+    await waitForImagesToLoad(bubbleElement)
+
     await setHeight()
   })
 

+ 56 - 1
app/frontend/shared/utils/__tests__/dom.spec.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
-import { domFrom } from '../dom.ts'
+import { domFrom, waitForImagesToLoad } from '../dom.ts'
 
 describe('domFrom', () => {
   const input = '<div>test</div>'
@@ -23,3 +23,58 @@ describe('domFrom', () => {
     expect(firstNode.childNodes[0]).toBeInstanceOf(Text)
   })
 })
+
+describe('waitForImagesToLoad', () => {
+  it('resolves immediately if no images are present', async () => {
+    const container = document.createElement('div')
+
+    const promise = await waitForImagesToLoad(container)
+
+    expect(promise).toEqual([])
+  })
+
+  it('resolves when all images load successfully', async () => {
+    const container = document.createElement('div')
+    const img1 = document.createElement('img')
+    const img2 = document.createElement('img')
+    container.appendChild(img1)
+    container.appendChild(img2)
+
+    const loadEvent = new Event('load')
+
+    setTimeout(() => {
+      img1.dispatchEvent(loadEvent)
+      img2.dispatchEvent(loadEvent)
+    }, 0)
+
+    const promises = await waitForImagesToLoad(container)
+
+    expect(promises).toHaveLength(2)
+    promises.forEach((promise) => {
+      expect(promise.status).toBe('fulfilled')
+    })
+  })
+
+  it('rejects if any image fails to load', async () => {
+    const container = document.createElement('div')
+    const img1 = document.createElement('img')
+    const img2 = document.createElement('img')
+    container.appendChild(img1)
+    container.appendChild(img2)
+
+    const loadEvent = new Event('error')
+    const errorEvent = new Event('error')
+
+    setTimeout(() => {
+      img1.dispatchEvent(loadEvent)
+      img2.dispatchEvent(errorEvent)
+    }, 0)
+
+    const promises = await waitForImagesToLoad(container)
+
+    promises.forEach((promise) => {
+      expect(promise.status).toBe('rejected')
+    })
+    expect(promises).toHaveLength(2)
+  })
+})

+ 38 - 0
app/frontend/shared/utils/dom.ts

@@ -1,5 +1,7 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
+import { type MaybeRef, toValue } from 'vue'
+
 import type { FormFieldValue } from '#shared/components/Form/types.ts'
 
 export const domFrom = (html: string, document_ = document) => {
@@ -21,3 +23,39 @@ export const removeSignatureFromBody = (input: FormFieldValue) => {
 
   return dom.innerHTML
 }
+
+/**
+ * Queries all images in the container and waits for them to load.
+ * */
+export const waitForImagesToLoad = async (container: MaybeRef) => {
+  const inlineImages: HTMLImageElement[] =
+    toValue(container).querySelectorAll('img')
+
+  if (inlineImages.length > 0) {
+    return Promise.allSettled<null>(
+      Array.from(inlineImages).map((image) => {
+        return new Promise((resolve, reject) => {
+          const cleanup = () => {
+            image.onload = null
+            image.onerror = null
+          }
+
+          const handleLoad = () => {
+            cleanup()
+            resolve(null)
+          }
+
+          const handleError = () => {
+            cleanup()
+            reject()
+          }
+
+          image.onload = handleLoad
+          image.onerror = handleError
+        })
+      }),
+    )
+  }
+
+  return Promise.allSettled<null>([])
+}