mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
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'
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user