fix(marketplace): hide detail close button during image preview

(cherry picked from commit e31856d7dc)

Preserve the deploy/dev gallery markup and adapt the regression selectors.
This commit is contained in:
CodingOnStar committed 2026-09-17 15:47:02 +08:00
1 parent 95674ae71e
commit 625c595409
5 files changed
+282 -17

No files matched your search

@@ -3,6 +3,30 @@ import userEvent from '@testing-library/user-event'
import ImageGallery from '..'
describe('ImageGallery', () => {
it('notifies the embedding owner while a preview is open, including unmount cleanup', async () => {
const user = userEvent.setup()
const onPreviewOpenChange = vi.fn()
const { unmount } = render(
<ImageGallery
srcs={['https://example.com/image.png']}
onPreviewOpenChange={onPreviewOpenChange}
/>,
)
expect(onPreviewOpenChange).not.toHaveBeenCalled()
await user.click(screen.getByTestId('gallery-image'))
expect(onPreviewOpenChange).toHaveBeenLastCalledWith(true)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(onPreviewOpenChange).toHaveBeenLastCalledWith(false)
await user.click(screen.getByTestId('gallery-image'))
expect(onPreviewOpenChange).toHaveBeenLastCalledWith(true)
unmount()
expect(onPreviewOpenChange).toHaveBeenLastCalledWith(false)
})
it('previews the selected image and closes on Escape', async () => {
const user = userEvent.setup()
render(
@@ -22,6 +46,30 @@ describe('ImageGallery', () => {
})
})
it('releases and restores the embedding owner across page navigation', async () => {
const user = userEvent.setup()
const onPreviewOpenChange = vi.fn()
render(
<ImageGallery
srcs={['https://example.com/image.png']}
onPreviewOpenChange={onPreviewOpenChange}
/>,
)
await user.click(screen.getByTestId('gallery-image'))
fireEvent(window, new Event('pagehide'))
expect(onPreviewOpenChange).toHaveBeenLastCalledWith(false)
fireEvent(window, new Event('pageshow'))
expect(onPreviewOpenChange).toHaveBeenLastCalledWith(true)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
onPreviewOpenChange.mockClear()
fireEvent(window, new Event('pageshow'))
expect(onPreviewOpenChange).not.toHaveBeenCalled()
})
it('removes an image that fails to load', () => {
render(<ImageGallery srcs={['https://example.com/broken.png']} />)
@@ -2,12 +2,13 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import s from './style.module.css'
type Props = Readonly<{
srcs: string[]
onPreviewOpenChange?: (open: boolean) => void
}>
const getWidthStyle = (imgNum: number) => {
@@ -28,8 +29,26 @@ const getWidthStyle = (imgNum: number) => {
}
}
const ImageGallery: FC<Props> = ({ srcs }) => {
const ImageGallery: FC<Props> = ({ srcs, onPreviewOpenChange }) => {
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const isPreviewOpen = Boolean(imagePreviewUrl)
// Synchronize embedding surfaces with the preview's lifetime, including
// navigation that unmounts the gallery before the preview is dismissed.
useEffect(() => {
if (!isPreviewOpen || !onPreviewOpenChange) return
onPreviewOpenChange(true)
const handlePageHide = () => onPreviewOpenChange(false)
const handlePageShow = () => onPreviewOpenChange(true)
window.addEventListener('pagehide', handlePageHide)
window.addEventListener('pageshow', handlePageShow)
return () => {
window.removeEventListener('pagehide', handlePageHide)
window.removeEventListener('pageshow', handlePageShow)
onPreviewOpenChange(false)
}
}, [isPreviewOpen, onPreviewOpenChange])
const imgNum = srcs.length
const imgStyle = getWidthStyle(imgNum)
@@ -0,0 +1,160 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import MarketplaceDetailDialogFrame from '../frame'
const title = 'Plugin details'
const src = 'about:blank?plugin=first'
const closeName = 'common.operation.close'
const previewMessage = { type: 'dify-marketplace:image-preview', open: true }
const getFrame = () => screen.getByTitle(title) as HTMLIFrameElement
function sendMessage(data: unknown, options?: MessageEventInit) {
fireEvent(
window,
new MessageEvent('message', {
data,
origin: 'null',
source: getFrame().contentWindow,
...options,
}),
)
}
describe('MarketplaceDetailDialogFrame image preview', () => {
it('lets the preview own closing until it closes, even without another message handler', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
render(
<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={onOpenChange} />,
)
fireEvent.load(getFrame())
sendMessage(previewMessage)
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
expect(screen.getByRole('dialog', { name: title })).toBeInTheDocument()
expect(onOpenChange).not.toHaveBeenCalled()
sendMessage({ ...previewMessage, open: false })
await user.click(screen.getByRole('button', { name: closeName }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('accepts preview updates only from this iframe and its expected origin', () => {
render(<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={vi.fn()} />)
sendMessage(previewMessage, { origin: 'https://another-marketplace.example' })
expect(screen.getByRole('button', { name: closeName })).toBeInTheDocument()
sendMessage(previewMessage, { source: window })
expect(screen.getByRole('button', { name: closeName })).toBeInTheDocument()
sendMessage(previewMessage)
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
sendMessage({ ...previewMessage, open: false }, { source: window })
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
})
it.each([
null,
'dify-marketplace:image-preview',
{ type: 'dify-marketplace:image-preview' },
{ type: 'dify-marketplace:image-preview', open: 'true' },
{ type: 'dify-marketplace:image-preview', open: 1 },
{ type: 'dify-marketplace:another-message', open: true },
])('ignores malformed or unrelated preview data: %j', (data) => {
render(<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={vi.fn()} />)
sendMessage(data)
expect(screen.getByRole('button', { name: closeName })).toBeInTheDocument()
})
it('continues forwarding other trusted marketplace messages and replies', () => {
const request = {
type: 'dify-marketplace:install-plugin',
pluginUniqueIdentifier: 'test/plugin:1',
}
const response = { type: 'dify-marketplace:install-plugin-status', status: 'success' }
const onMessage = vi.fn((_data, reply) => reply(response))
render(
<MarketplaceDetailDialogFrame
open
src={src}
title={title}
onMessage={onMessage}
onOpenChange={vi.fn()}
/>,
)
const postMessage = vi.spyOn(getFrame().contentWindow!, 'postMessage')
sendMessage(request)
expect(onMessage).toHaveBeenCalledWith(request, expect.any(Function))
expect(postMessage).toHaveBeenCalledWith(response, 'null')
})
it('keeps an active preview above the close button when the iframe load arrives after fallback reveal', async () => {
vi.useFakeTimers()
try {
render(<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(15_000))
expect(getFrame()).not.toHaveAttribute('inert')
sendMessage(previewMessage)
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
fireEvent.load(getFrame())
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('starts another detail URL with its own close button', () => {
const onOpenChange = vi.fn()
const { rerender } = render(
<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={onOpenChange} />,
)
sendMessage(previewMessage)
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
rerender(
<MarketplaceDetailDialogFrame
open
src="about:blank?plugin=second"
title={title}
onOpenChange={onOpenChange}
/>,
)
expect(screen.getByRole('button', { name: closeName })).toBeInTheDocument()
})
it('restores the close button after the controlled details dialog closes and reopens', async () => {
const onOpenChange = vi.fn()
const { rerender } = render(
<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={onOpenChange} />,
)
sendMessage(previewMessage)
expect(screen.queryByRole('button', { name: closeName })).not.toBeInTheDocument()
rerender(
<MarketplaceDetailDialogFrame
open={false}
src={src}
title={title}
onOpenChange={onOpenChange}
/>,
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
rerender(
<MarketplaceDetailDialogFrame open src={src} title={title} onOpenChange={onOpenChange} />,
)
expect(screen.getByRole('button', { name: closeName })).toBeInTheDocument()
})
})
@@ -12,6 +12,7 @@ import {
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { isMarketplaceImagePreviewMessage } from './image-preview-message'
type ReplyToMarketplaceFrame = (data: unknown) => void
@@ -39,6 +40,12 @@ export default function MarketplaceDetailDialogFrame({
const iframeRef = useRef<HTMLIFrameElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const [isLoading, setIsLoading] = useState(true)
const [imagePreview, setImagePreview] = useState({ src, dialogOpen: open, open: false })
// A controlled close/reopen or another detail URL starts a fresh frame
// session, including when a close transition has not finished unmounting.
if (imagePreview.src !== src || imagePreview.dialogOpen !== open)
setImagePreview({ src, dialogOpen: open, open: false })
useEffect(() => {
if (!open) return
@@ -48,14 +55,19 @@ export default function MarketplaceDetailDialogFrame({
}, [open, src])
useEffect(() => {
if (!open || !onMessage) return
if (!open) return
const marketplaceOrigin = new URL(src, window.location.href).origin
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow || event.origin !== marketplaceOrigin)
return
onMessage(event.data, (payload) => {
if (isMarketplaceImagePreviewMessage(event.data)) {
setImagePreview({ src, dialogOpen: open, open: event.data.open })
return
}
onMessage?.(event.data, (payload) => {
iframeRef.current?.contentWindow?.postMessage(payload, marketplaceOrigin)
})
}
@@ -65,7 +77,10 @@ export default function MarketplaceDetailDialogFrame({
}, [onMessage, open, src])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setIsLoading(true)
if (!nextOpen) {
setIsLoading(true)
setImagePreview({ src, dialogOpen: open, open: false })
}
onOpenChange(nextOpen)
}
@@ -119,18 +134,22 @@ export default function MarketplaceDetailDialogFrame({
src={src}
title={title}
/>
<DialogClose
render={
<IconButton
ref={closeButtonRef}
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="sm"
className="absolute top-5 right-5 z-10 size-8 rounded-lg"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
{/* The image preview lives inside a different document, so its
backdrop cannot cover this host control through z-index. */}
{!imagePreview.open && (
<DialogClose
render={
<IconButton
ref={closeButtonRef}
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="sm"
className="absolute top-5 right-5 z-10 size-8 rounded-lg"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
)}
</DialogPopup>
</DialogPortal>
</Dialog>
@@ -0,0 +1,19 @@
export const MARKETPLACE_IMAGE_PREVIEW_MESSAGE = 'dify-marketplace:image-preview'
export type MarketplaceImagePreviewMessage = {
type: typeof MARKETPLACE_IMAGE_PREVIEW_MESSAGE
open: boolean
}
export function isMarketplaceImagePreviewMessage(
data: unknown,
): data is MarketplaceImagePreviewMessage {
return (
typeof data === 'object' &&
data !== null &&
'type' in data &&
data.type === MARKETPLACE_IMAGE_PREVIEW_MESSAGE &&
'open' in data &&
typeof data.open === 'boolean'
)
}