feat: add marketplace template detail dialog

This commit is contained in:
CodingOnStar 2026-08-05 18:09:05 +08:00
parent fe05ace87b
commit 8220f05b34
6 changed files with 333 additions and 136 deletions

View File

@ -0,0 +1,96 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
type MarketplaceDetailDialogFrameProps = {
open: boolean
src: string
title: string
onMessage?: (data: unknown) => void
onOpenChange: (open: boolean) => void
}
export default function MarketplaceDetailDialogFrame({
open,
src,
title,
onMessage,
onOpenChange,
}: MarketplaceDetailDialogFrameProps) {
const { t } = useTranslation()
const iframeRef = useRef<HTMLIFrameElement>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!open || !onMessage) 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)
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [onMessage, open, src])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setIsLoading(true)
onOpenChange(nextOpen)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="h-[min(800px,calc(100dvh-48px))] max-h-none! w-[min(1200px,calc(100vw-48px))] max-w-none! overflow-hidden! border-0 p-0 shadow-xl">
<DialogTitle className="sr-only">{title}</DialogTitle>
<div
aria-hidden
className={cn(
'absolute inset-0 bg-background-default transition-opacity',
isLoading ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<div className="flex h-[52px] items-center px-6">
<div className="h-4 w-40 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="mx-auto flex w-full max-w-[1000px] gap-8 px-12 py-8">
<div className="flex flex-1 flex-col gap-4">
<div className="h-16 w-2/3 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
<div className="h-4 w-full animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
<div className="h-4 w-5/6 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
<div className="mt-8 h-72 w-full animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="hidden w-60 flex-col gap-4 lg:flex">
<div className="h-24 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
<div className="h-52 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
</div>
</div>
</div>
<iframe
ref={iframeRef}
className={cn(
'size-full border-0 bg-background-default transition-opacity',
isLoading ? 'opacity-0' : 'opacity-100',
)}
onLoad={() => setIsLoading(false)}
referrerPolicy="strict-origin-when-cross-origin"
src={src}
title={title}
/>
<DialogCloseButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="top-5 right-5 size-8 rounded-lg"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-linear-to-t from-background-default to-transparent"
/>
</DialogContent>
</Dialog>
)
}

View File

@ -1,12 +1,11 @@
'use client'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useTheme } from 'next-themes'
import { useEffect, useRef, useState } from 'react'
import { useCallback } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { getPluginLinkInMarketplace } from '../utils'
import MarketplaceDetailDialogFrame from './frame'
const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-plugin'
@ -28,8 +27,6 @@ function MarketplaceDetailDialog({
const { t } = useTranslation()
const locale = useLocale()
const { theme } = useTheme()
const iframeRef = useRef<HTMLIFrameElement>(null)
const [isLoading, setIsLoading] = useState(true)
const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
const detailURL = getPluginLinkInMarketplace(plugin, {
@ -40,85 +37,31 @@ function MarketplaceDetailDialog({
view: 'modal',
})
useEffect(() => {
if (!open || isInstalled) return
const marketplaceOrigin = new URL(detailURL, window.location.href).origin
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow || event.origin !== marketplaceOrigin)
return
const handleMessage = useCallback(
(data: unknown) => {
if (
typeof event.data !== 'object' ||
event.data === null ||
event.data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE ||
event.data.pluginUniqueIdentifier !== plugin.latest_package_identifier
typeof data !== 'object' ||
data === null ||
!('type' in data) ||
!('pluginUniqueIdentifier' in data) ||
data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE ||
data.pluginUniqueIdentifier !== plugin.latest_package_identifier
)
return
onInstall()
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [detailURL, isInstalled, onInstall, open, plugin.latest_package_identifier])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setIsLoading(true)
onOpenChange(nextOpen)
}
},
[onInstall, plugin.latest_package_identifier],
)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="h-[min(800px,calc(100dvh-48px))] max-h-none! w-[min(1200px,calc(100vw-48px))] max-w-none! overflow-hidden! border-0 p-0 shadow-xl">
<DialogTitle className="sr-only">
{pluginLabel}
{' · '}
{detailLabel}
</DialogTitle>
<div
aria-hidden
className={cn(
'absolute inset-0 bg-background-default transition-opacity',
isLoading ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<div className="flex h-[52px] items-center px-6">
<div className="h-4 w-40 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="mx-auto flex w-full max-w-[1000px] gap-8 px-12 py-8">
<div className="flex flex-1 flex-col gap-4">
<div className="h-16 w-2/3 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
<div className="h-4 w-full animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
<div className="h-4 w-5/6 animate-pulse rounded-md bg-state-base-hover motion-reduce:animate-none" />
<div className="mt-8 h-72 w-full animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="hidden w-60 flex-col gap-4 lg:flex">
<div className="h-24 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
<div className="h-52 animate-pulse rounded-xl bg-state-base-hover motion-reduce:animate-none" />
</div>
</div>
</div>
<iframe
ref={iframeRef}
className={cn(
'size-full border-0 bg-background-default transition-opacity',
isLoading ? 'opacity-0' : 'opacity-100',
)}
onLoad={() => setIsLoading(false)}
referrerPolicy="strict-origin-when-cross-origin"
src={detailURL}
title={`${pluginLabel} · ${detailLabel}`}
/>
<DialogCloseButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="top-5 right-5 size-8 rounded-lg"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-linear-to-t from-background-default to-transparent"
/>
</DialogContent>
</Dialog>
<MarketplaceDetailDialogFrame
open={open}
src={detailURL}
title={`${pluginLabel} · ${detailLabel}`}
onMessage={isInstalled ? undefined : handleMessage}
onOpenChange={onOpenChange}
/>
)
}

View File

@ -1,8 +1,26 @@
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TemplateCard from '../template-card'
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('../../utils', () => ({
getTemplateLinkInMarketplace: (
currentTemplate: MarketplaceTemplate,
params: { language: string; source?: string; theme?: string; view: string },
) =>
`about:blank?templateId=${currentTemplate.id}&language=${params.language}&source=${params.source}&theme=${params.theme}&view=${params.view}`,
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div aria-hidden />,
}))
@ -21,13 +39,51 @@ const template: MarketplaceTemplate = {
}
describe('TemplateCard', () => {
it('opens a Marketplace template through the Dify import flow', () => {
render(<TemplateCard partnerText="Verified by a Dify partner" template={template} />)
beforeEach(() => {
vi.clearAllMocks()
})
expect(screen.getByRole('link', { name: 'Campaign planner' })).toHaveAttribute(
'href',
'/apps?template-id=template%2Fone',
it('opens template detail before starting the Dify import flow', async () => {
const user = userEvent.setup()
render(
<ThemeProvider forcedTheme="dark">
<TemplateCard partnerText="Verified by a Dify partner" template={template} />
</ThemeProvider>,
)
expect(screen.queryByRole('link', { name: 'Campaign planner' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Campaign planner' }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(mockPush).not.toHaveBeenCalled()
const frame = screen.getByTitle(
'Campaign planner · plugin.detailPanel.operation.detail',
) as HTMLIFrameElement
const marketplaceOrigin = new URL(frame.getAttribute('src')!, window.location.href).origin
const installRequest = {
type: 'dify-marketplace:install-template',
templateId: template.id,
}
fireEvent(
window,
new MessageEvent('message', {
data: { ...installRequest, templateId: 'another-template' },
origin: marketplaceOrigin,
source: frame.contentWindow,
}),
)
expect(mockPush).not.toHaveBeenCalled()
fireEvent(
window,
new MessageEvent('message', {
data: installRequest,
origin: marketplaceOrigin,
source: frame.contentWindow,
}),
)
expect(mockPush).toHaveBeenCalledWith('/apps?template-id=template%2Fone')
expect(screen.getByText('dify')).toBeInTheDocument()
expect(screen.getByText('1.2k')).toBeInTheDocument()
expect(screen.getByLabelText('Verified by a Dify partner')).toBeInTheDocument()

View File

@ -1,11 +1,16 @@
'use client'
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import { cn } from '@langgenius/dify-ui/cn'
import { useBoolean } from 'ahooks'
import { useCallback } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import Partner from '@/app/components/plugins/base/badges/partner'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { formatNumberAbbreviated } from '@/utils/format'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import TemplateDetailDialog from './template-detail-dialog'
type TemplateCardProps = {
template: MarketplaceTemplate
@ -16,6 +21,8 @@ type TemplateCardProps = {
const MAX_VISIBLE_PLUGIN_DEPENDENCIES = 7
export default function TemplateCard({ template, className, partnerText }: TemplateCardProps) {
const router = useRouter()
const [isDetailOpen, { setTrue: showDetail, setFalse: hideDetail }] = useBoolean(false)
const publisher =
template.publisher_handle || template.publisher_unique_handle || template.creator_email || ''
const visiblePlugins = template.deps_plugins?.slice(0, MAX_VISIBLE_PLUGIN_DEPENDENCIES) ?? []
@ -26,60 +33,77 @@ export default function TemplateCard({ template, className, partnerText }: Templ
const imageUrl = template.icon_file_key
? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
: undefined
const handleOpenChange = (open: boolean) => {
if (open) showDetail()
else hideDetail()
}
const handleInstall = useCallback(() => {
hideDetail()
router.push(`/apps?template-id=${encodeURIComponent(template.id)}`)
}, [hideDetail, router, template.id])
return (
<article
className={cn(
'relative flex h-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
className,
)}
>
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
<AppIcon
size="large"
iconType={imageUrl ? 'image' : 'emoji'}
icon={imageUrl ? undefined : template.icon || '📄'}
imageUrl={imageUrl}
background={template.icon_background}
/>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<div className="flex items-center">
<Link
href={`/apps?template-id=${encodeURIComponent(template.id)}`}
className="truncate system-md-medium text-text-primary after:absolute after:inset-0"
>
{template.template_name}
</Link>
{template.badges?.includes('partner') && (
<Partner className="relative z-[1] ml-0.5 size-4 shrink-0" text={partnerText} />
)}
</div>
<div className="flex items-center gap-2 system-xs-regular text-text-tertiary">
{publisher && <span className="truncate">{publisher}</span>}
{publisher && <span>·</span>}
<span>{formatNumberAbbreviated(template.usage_count)}</span>
<>
<article
className={cn(
'relative flex h-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
className,
)}
>
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
<AppIcon
size="large"
iconType={imageUrl ? 'image' : 'emoji'}
icon={imageUrl ? undefined : template.icon || '📄'}
imageUrl={imageUrl}
background={template.icon_background}
/>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<div className="flex items-center">
<button
type="button"
onClick={showDetail}
className="truncate text-left system-md-medium text-text-primary outline-hidden after:absolute after:inset-0 focus-visible:after:ring-2 focus-visible:after:ring-state-accent-solid focus-visible:after:ring-inset"
>
{template.template_name}
</button>
{template.badges?.includes('partner') && (
<Partner className="relative z-[1] ml-0.5 size-4 shrink-0" text={partnerText} />
)}
</div>
<div className="flex items-center gap-2 system-xs-regular text-text-tertiary">
{publisher && <span className="truncate">{publisher}</span>}
{publisher && <span>·</span>}
<span>{formatNumberAbbreviated(template.usage_count)}</span>
</div>
</div>
</div>
</div>
<div className="min-h-8 px-4 pt-1 pb-2 system-xs-regular text-text-secondary">
<p className="line-clamp-2" title={template.overview}>
{template.overview}
</p>
</div>
<div className="mt-auto flex min-h-7 items-center gap-1 px-4 py-1">
{visiblePlugins.map((pluginId) => (
<img
key={pluginId}
className="size-6 rounded-md border-[0.5px] border-effects-icon-border object-cover"
src={getIconFromMarketPlace(pluginId)}
alt=""
title={pluginId}
/>
))}
{remainingPluginCount > 0 && (
<span className="system-xs-regular text-text-tertiary">+{remainingPluginCount}</span>
)}
</div>
</article>
<div className="min-h-8 px-4 pt-1 pb-2 system-xs-regular text-text-secondary">
<p className="line-clamp-2" title={template.overview}>
{template.overview}
</p>
</div>
<div className="mt-auto flex min-h-7 items-center gap-1 px-4 py-1">
{visiblePlugins.map((pluginId) => (
<img
key={pluginId}
className="size-6 rounded-md border-[0.5px] border-effects-icon-border object-cover"
src={getIconFromMarketPlace(pluginId)}
alt=""
title={pluginId}
/>
))}
{remainingPluginCount > 0 && (
<span className="system-xs-regular text-text-tertiary">+{remainingPluginCount}</span>
)}
</div>
</article>
<TemplateDetailDialog
open={isDetailOpen}
template={template}
onInstall={handleInstall}
onOpenChange={handleOpenChange}
/>
</>
)
}

View File

@ -0,0 +1,61 @@
'use client'
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import { useTheme } from 'next-themes'
import { useCallback } from 'react'
import { useLocale, useTranslation } from '#i18n'
import MarketplaceDetailDialogFrame from '../detail-dialog/frame'
import { getTemplateLinkInMarketplace } from '../utils'
const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-template'
type TemplateDetailDialogProps = {
open: boolean
template: MarketplaceTemplate
onInstall: () => void
onOpenChange: (open: boolean) => void
}
export default function TemplateDetailDialog({
open,
template,
onInstall,
onOpenChange,
}: TemplateDetailDialogProps) {
const { t } = useTranslation()
const locale = useLocale()
const { theme } = useTheme()
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
const detailURL = getTemplateLinkInMarketplace(template, {
language: locale,
source: globalThis.location?.origin,
theme,
view: 'modal',
})
const handleMessage = useCallback(
(data: unknown) => {
if (
typeof data !== 'object' ||
data === null ||
!('type' in data) ||
!('templateId' in data) ||
data.type !== MARKETPLACE_INSTALL_MESSAGE_TYPE ||
data.templateId !== template.id
)
return
onInstall()
},
[onInstall, template.id],
)
return (
<MarketplaceDetailDialogFrame
open={open}
src={detailURL}
title={`${template.template_name} · ${detailLabel}`}
onMessage={handleMessage}
onOpenChange={onOpenChange}
/>
)
}

View File

@ -2,6 +2,7 @@ import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
MarketplacePlugin,
MarketplaceTemplate,
PluginsSearchParams,
} from '@dify/contracts/marketplace'
import type { ActivePluginType } from './constants'
@ -70,6 +71,22 @@ export const getPluginDetailLinkInMarketplace = (
return `/plugin/${org}/${name}`
}
export const getTemplateLinkInMarketplace = (
template: Pick<
MarketplaceTemplate,
'id' | 'publisher_handle' | 'publisher_unique_handle' | 'template_name'
>,
params?: Record<string, string | undefined>,
) => {
const publisher = template.publisher_handle || template.publisher_unique_handle || 'template'
const path = `/template/${encodeURIComponent(publisher)}/${encodeURIComponent(template.template_name)}`
return getMarketplaceUrl(path, {
...params,
templateId: template.id,
})
}
export const getMarketplaceCategoryUrl = (
category?: string,
params?: Record<string, string | undefined>,