fix(web): open embedded recommend banner plugins in the detail dialog (#41828)

Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 姜涵煦 <hanxujiang@jianghanxudeMacBook-Pro-2.local>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-09-07 03:19:07 +00:00 committed by GitHub
parent 4b0e260ac5
commit 762dc5e8a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 378 additions and 73 deletions

View File

@ -0,0 +1,40 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { highlightCode } from '../shiki-highlight'
describe('README code highlighting', () => {
it.each(['github-light', 'github-dark'] as const)('highlights dotenv with %s', async (theme) => {
const code = 'OPENAI_API_KEY=your-api-key\n# OPENAI_ORGANIZATION=org-id'
const result = renderToStaticMarkup(await highlightCode({ code, language: 'dotenv', theme }))
expect(result).toContain('OPENAI_API_KEY')
expect(result).toContain('your-api-key')
expect(result).toContain('OPENAI_ORGANIZATION=org-id')
expect(result).toContain('<span style="color:')
})
it('renders unsupported languages as readable, escaped plain text', async () => {
const code = '<custom>example</custom>'
const result = renderToStaticMarkup(
await highlightCode({
code,
language: 'unknown-readme-language',
theme: 'github-light',
}),
)
expect(result).toContain('&lt;custom&gt;example&lt;/custom&gt;')
})
it('preserves highlighting for bundled language aliases', async () => {
const result = renderToStaticMarkup(
await highlightCode({
code: 'const count = 1',
language: 'js',
theme: 'github-light',
}),
)
expect(result).toContain('const')
expect(result).toContain('<span style="color:')
})
})

View File

@ -1,5 +1,5 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import type { BundledTheme } from 'shiki/bundle/web'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Toggle } from '@langgenius/dify-ui/toggle'
import ReactEcharts from 'echarts-for-react'
@ -78,7 +78,7 @@ const ShikiCodeBlock = memo(
void highlightCode({
code,
language: language as BundledLanguage,
language,
theme,
})
.then((result) => {

View File

@ -1,13 +1,13 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import type { BundledTheme } from 'shiki/bundle/web'
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
import { Fragment } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
import { codeToHast } from 'shiki/bundle/web'
import { bundledLanguages, getSingletonHighlighter } from 'shiki/bundle/web'
type HighlightCodeOptions = {
code: string
language: BundledLanguage
language: string
theme: BundledTheme
}
@ -16,8 +16,19 @@ export const highlightCode = async ({
language,
theme,
}: HighlightCodeOptions): Promise<JSX.Element> => {
const hast = await codeToHast(code, {
lang: language,
const normalizedLanguage = language.trim().toLowerCase()
const lang =
normalizedLanguage === 'dotenv' || Object.hasOwn(bundledLanguages, normalizedLanguage)
? normalizedLanguage
: 'text'
// README fences may name languages outside the web bundle. Load dotenv on
// demand and keep unknown languages readable without throwing an error.
const highlighter = await getSingletonHighlighter({
langs: lang === 'dotenv' ? [(await import('shiki/langs/dotenv.mjs')).default] : [lang],
themes: [theme],
})
const hast = highlighter.codeToHast(code, {
lang,
theme,
})

View File

@ -163,6 +163,34 @@ describe('useMarketplaceData', () => {
document.body.removeChild(container)
})
it('restores collections and clears pending results when switching Models back to All', async () => {
const { useMarketplaceData } = await import('../state')
const { useActivePluginType } = await import('../atoms')
const { Wrapper } = createWrapper('?category=model')
const { result } = renderHook(
() => ({
data: useMarketplaceData(),
setCategory: useActivePluginType()[1],
}),
{ wrapper: Wrapper },
)
await waitFor(() => {
expect(result.current.data.plugins).toHaveLength(1)
})
await act(async () => {
await result.current.setCategory(PLUGIN_TYPE_SEARCH_MAP.all)
})
await waitFor(() => {
expect(result.current.data.marketplaceCollections).toHaveLength(1)
expect(result.current.data.plugins).toBeUndefined()
expect(result.current.data.pluginsTotal).toBeUndefined()
expect(result.current.data.isRefreshing).toBe(false)
})
})
it('should use the server route category for hydrated standalone search', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?q=openai')

View File

@ -37,6 +37,31 @@ vi.mock('@/config', async (importOriginal) => ({
MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({ installedInfo: {} }),
}))
vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: vi.fn().mockResolvedValue({
data: {
plugin: {
category: 'tool',
latest_package_identifier: 'langgenius/dropbox:1.0.0',
latest_version: '1.0.0',
},
},
}),
}))
vi.mock('../../detail-dialog', () => ({
default: ({ open, plugin }: { open: boolean; plugin: { name: string } }) =>
open ? (
<div role="dialog" aria-label="plugin-detail">
{plugin.name}
</div>
) : null,
}))
const banners: PluginBanner[] = [
{
id: 'recommend',
@ -678,7 +703,8 @@ describe('HomeTrending', () => {
})
})
it('sends embedded cards without a delivery link to the marketplace site', () => {
it('opens embedded recommend plugin cards in the plugin dialog', async () => {
const user = userEvent.setup()
const bannerWithMixedLinks: PluginBanner = {
id: 'recommend-mixed',
style_type: 'recommend',
@ -696,8 +722,6 @@ describe('HomeTrending', () => {
card_position: 0,
},
{
// The console has no local /plugin route, so a card without a
// delivery-provided link must open the marketplace detail page.
item_type: 'plugin',
item_id: 'langgenius/notion',
display_name: 'Notion',
@ -723,19 +747,34 @@ describe('HomeTrending', () => {
/>,
)
expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
'href',
'https://external.example.com/dropbox',
)
const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' })
expect(marketplaceFallbackLink.getAttribute('href')).toMatch(
/^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/,
)
expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank')
expect(screen.queryByRole('link', { name: 'Dropbox' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Notion' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
'href',
'/templates?tid=tpl-1',
)
await user.click(screen.getByRole('button', { name: 'Dropbox' }))
expect(screen.getByRole('dialog', { name: 'plugin-detail' })).toHaveTextContent('dropbox')
expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith(
'marketplace_banner_click',
expect.objectContaining({
click_target: 'recommendation',
item_id: 'langgenius/dropbox',
item_type: 'plugin',
item_name: 'Dropbox',
}),
)
})
it('keeps standalone recommend plugin cards on local detail routes', () => {
render(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />)
expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
'href',
'/plugin/langgenius/dropbox',
)
})
it('clamps the active slide when a refetch shrinks the banner list', async () => {

View File

@ -9,17 +9,23 @@ import type {
PluginBanner,
} from '@dify/contracts/marketplace'
import type { MarketplaceBannerPage } from './banners'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import { useTranslation } from '#i18n'
import { trackEvent } from '@/app/components/base/amplitude'
import Partner from '@/app/components/plugins/base/badges/partner'
import Verified from '@/app/components/plugins/base/badges/verified'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Link from '@/next/link'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import {
rememberMarketplaceSiteReferrer,
trackMarketplaceSiteEvent,
} from '@/utils/marketplace-site-track'
import MarketplaceDetailDialog from '../detail-dialog'
import { getPluginLinkInMarketplace } from '../utils'
import background from './assets/background.webp'
import difyUpdatesArt from './assets/dify-updates-art.png'
@ -46,11 +52,49 @@ const getMarketplaceAssetURL = (path?: string) => {
}
}
const getPluginIdentity = (itemId: string) => {
const [org, name] = itemId.split('/')
if (!org || !name) return null
return { org, name }
}
const pluginFromRecommendCard = (card: BannerRecommendCard): Plugin | null => {
if (card.item_type !== 'plugin') return null
const identity = getPluginIdentity(card.item_id)
if (!identity) return null
return {
type: 'plugin',
org: identity.org,
name: identity.name,
plugin_id: card.item_id,
version: '',
latest_version: '',
latest_package_identifier: '',
icon: card.icon_url ?? '',
verified: Boolean(card.badges?.includes('verified')),
label: { 'en-US': card.display_name },
brief: {},
description: {},
introduction: '',
repository: '',
category: PluginCategoryEnum.tool,
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: card.badges ?? null,
verification: {
authorized_category: card.badges?.includes('partner') ? 'partner' : 'community',
},
from: 'marketplace',
}
}
const getLocalCardHref = (card: BannerRecommendCard) => {
if (card.item_type === 'plugin') {
const [organization, pluginName] = card.item_id.split('/')
if (organization && pluginName)
return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}`
const identity = getPluginIdentity(card.item_id)
if (identity)
return `/plugin/${encodeURIComponent(identity.org)}/${encodeURIComponent(identity.name)}`
}
if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}`
@ -63,17 +107,14 @@ const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean)
const deliveryHref = card.link ? sanitizeMarketplaceHref(card.link) : null
if (deliveryHref) return deliveryHref
// The embedded console has no local plugin detail route, so a plugin card
// without a delivery-provided link opens the marketplace site detail page.
if (card.item_type === 'plugin') {
const [organization, pluginName] = card.item_id.split('/')
if (organization && pluginName)
return getPluginLinkInMarketplace({ org: organization, name: pluginName, type: 'plugin' })
}
return getLocalCardHref(card)
}
const recommendCardClassName = cn(
styles.card,
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)
const getCardCreator = (card: BannerRecommendCard) => {
if (card.creator) return card.creator
if (card.item_type !== 'plugin') return ''
@ -141,54 +182,38 @@ function TrendingCopy({
)
}
function TrendingCard({
banner,
card,
isMarketplacePlatform,
page,
}: {
banner: BannerRecommend
card: BannerRecommendCard
isMarketplacePlatform: boolean
page: MarketplaceBannerPage
}) {
function trackRecommendCardClick(
banner: BannerRecommend,
card: BannerRecommendCard,
page: MarketplaceBannerPage,
href: string,
) {
trackEvent('marketplace_banner_item_click', {
...getBannerFrameProps(banner, page),
item_type: card.item_type,
item_id: card.item_id,
card_position: card.card_position,
theme_type: banner.content.theme_type,
auto_batch_id: card.auto_batch_id ?? null,
})
rememberMarketplaceSiteReferrer(card.item_id, 'banner')
trackMarketplaceBannerClick(banner, {
item_id: card.item_id,
item_type: card.item_type,
display_name: card.display_name,
link: href,
})
}
function RecommendCardFace({ card }: { card: BannerRecommendCard }) {
const { t } = useTranslation('plugin')
const iconURL = getMarketplaceAssetURL(card.icon_url)
const creator = getCardCreator(card)
const href = getCardHref(card, isMarketplacePlatform)
if (!href) return null
const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
const isPartner = card.badges?.includes('partner')
const isVerified = card.badges?.includes('verified')
return (
<Link
href={href}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={card.display_name}
onClick={() => {
trackEvent('marketplace_banner_item_click', {
...getBannerFrameProps(banner, page),
item_type: card.item_type,
item_id: card.item_id,
card_position: card.card_position,
theme_type: banner.content.theme_type,
auto_batch_id: card.auto_batch_id ?? null,
})
rememberMarketplaceSiteReferrer(card.item_id, 'banner')
trackMarketplaceBannerClick(banner, {
item_id: card.item_id,
item_type: card.item_type,
display_name: card.display_name,
link: href,
})
}}
className={cn(
styles.card,
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
>
<>
<div
className={cn(
styles.cardIcon,
@ -241,6 +266,110 @@ function TrendingCard({
{t(($) => $['marketplace.home.trendingView'])}
</span>
</div>
</>
)
}
function EmbeddedRecommendPluginCard({
banner,
card,
initialPlugin,
page,
}: {
banner: BannerRecommend
card: BannerRecommendCard
initialPlugin: Plugin
page: MarketplaceBannerPage
}) {
const [open, setOpen] = useState(false)
const [plugin, setPlugin] = useState(initialPlugin)
if (plugin.plugin_id !== initialPlugin.plugin_id) setPlugin(initialPlugin)
const { installedInfo } = useCheckInstalled({
pluginIds: [plugin.plugin_id],
enabled: open,
})
const href = getPluginLinkInMarketplace({
org: plugin.org,
name: plugin.name,
type: 'plugin',
})
return (
<>
<button
type="button"
aria-label={card.display_name}
className={cn(recommendCardClassName, 'cursor-pointer border-0 text-left')}
onClick={() => {
trackRecommendCardClick(banner, card, page, href)
setOpen(true)
if (plugin.latest_package_identifier) return
void fetchPluginInfoFromMarketPlace({ org: plugin.org, name: plugin.name })
.then((response) => {
const info = response.data.plugin
setPlugin((current) => ({
...current,
latest_package_identifier: info.latest_package_identifier,
latest_version: info.latest_version,
version: info.latest_version,
category: (info.category as Plugin['category']) ?? current.category,
}))
})
.catch(() => {})
}}
>
<RecommendCardFace card={card} />
</button>
<MarketplaceDetailDialog
isInstalled={Boolean(installedInfo?.[plugin.plugin_id])}
open={open}
plugin={plugin}
onOpenChange={setOpen}
/>
</>
)
}
function TrendingCard({
banner,
card,
isMarketplacePlatform,
page,
}: {
banner: BannerRecommend
card: BannerRecommendCard
isMarketplacePlatform: boolean
page: MarketplaceBannerPage
}) {
const embeddedPlugin = isMarketplacePlatform ? null : pluginFromRecommendCard(card)
if (embeddedPlugin) {
return (
<EmbeddedRecommendPluginCard
banner={banner}
card={card}
initialPlugin={embeddedPlugin}
page={page}
/>
)
}
const href = getCardHref(card, isMarketplacePlatform)
if (!href) return null
const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
return (
<Link
href={href}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={card.display_name}
onClick={() => {
trackRecommendCardClick(banner, card, page, href)
}}
className={recommendCardClassName}
>
<RecommendCardFace card={card} />
</Link>
)
}

View File

@ -0,0 +1,55 @@
import type { Plugin } from '@/app/components/plugins/types'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import List from '../index'
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({ installedInfo: {} }),
}))
const plugins = Array.from({ length: 5 }, (_, index) => ({
plugin_id: `publisher/plugin-${index}`,
org: 'publisher',
name: `Plugin ${index + 1}`,
})) as Plugin[]
describe('Marketplace search result layout', () => {
// Native grid layout determines whether the result cards remain readable;
// happy-dom cannot reproduce the four 75px columns seen on mobile.
it.each([
{ viewportWidth: 390, columns: 1 },
{ viewportWidth: 1280, columns: 4 },
])('keeps readable cards at $viewportWidth px', async ({ viewportWidth, columns }) => {
await page.viewport(viewportWidth, 844)
const screen = await render(
<div style={{ width: viewportWidth - 40 }}>
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={plugins}
cardRender={(plugin) => (
<a key={plugin.plugin_id} href={`/plugin/${plugin.plugin_id}`}>
{plugin.name}
</a>
)}
/>
</div>,
)
const first = screen.getByRole('link', { name: 'Plugin 1' }).element().getBoundingClientRect()
const nextRow = screen
.getByRole('link', { name: `Plugin ${columns + 1}` })
.element()
.getBoundingClientRect()
expect(first.width).toBeGreaterThanOrEqual(250)
expect(nextRow.top).toBeGreaterThanOrEqual(first.bottom)
if (columns > 1) {
const lastInRow = screen
.getByRole('link', { name: `Plugin ${columns}` })
.element()
.getBoundingClientRect()
expect(lastInRow.top).toBe(first.top)
}
})
})

View File

@ -8,6 +8,7 @@ import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import Empty from '../empty'
import CardWrapper from './card-wrapper'
import { GRID_CLASS } from './collection-constants'
import ListWithCollection from './list-with-collection'
type ListProps = {
@ -77,7 +78,7 @@ const List = ({
/>
)}
{plugins && !!plugins.length && (
<div className={cn('grid grid-cols-4 gap-3', cardContainerClassName)}>
<div className={cn(GRID_CLASS, cardContainerClassName)}>
{plugins.map((plugin) => {
if (cardRender) return cardRender(plugin)

View File

@ -26,7 +26,9 @@ export const getMarketplacePluginsInfiniteQueryOptions = (
// the grid unmounts, the container collapses, and the scroll position jumps —
// the "jitter" the Marketplace search is reported for. Consumers show a
// quiet pending state off `isPlaceholderData` instead.
placeholderData: keepPreviousData,
// Returning to collections disables search; keeping its placeholder then
// leaves the last category visible and permanently marked as refreshing.
placeholderData: queryParams === undefined ? undefined : keepPreviousData,
// Matches the autocomplete queries. Now that the fetcher propagates
// failures, react-query's default of 3 retries would hold isFetching true
// through ~7s of backoff — indistinguishable from a hang. Failing fast and