From 762dc5e8a627deb7e70139306237b6a70c5dc1de Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 7 Sep 2026 03:19:07 +0000 Subject: [PATCH] fix(web): open embedded recommend banner plugins in the detail dialog (#41828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: fatelei Co-authored-by: zxhlyh Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: 姜涵煦 Co-authored-by: L1nSn0w Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../__tests__/shiki-highlight.spec.tsx | 40 +++ .../base/markdown-blocks/code-block.tsx | 4 +- .../base/markdown-blocks/shiki-highlight.tsx | 21 +- .../marketplace/__tests__/state.spec.tsx | 28 +++ .../home/__tests__/home-trending.spec.tsx | 63 ++++- .../marketplace/home/home-trending-slides.tsx | 233 ++++++++++++++---- .../search-results-layout.browser.spec.tsx | 55 +++++ .../plugins/marketplace/list/index.tsx | 3 +- .../plugins/marketplace/query-options.ts | 4 +- 9 files changed, 378 insertions(+), 73 deletions(-) create mode 100644 web/app/components/base/markdown-blocks/__tests__/shiki-highlight.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/__tests__/search-results-layout.browser.spec.tsx diff --git a/web/app/components/base/markdown-blocks/__tests__/shiki-highlight.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/shiki-highlight.spec.tsx new file mode 100644 index 00000000000..7c30b275259 --- /dev/null +++ b/web/app/components/base/markdown-blocks/__tests__/shiki-highlight.spec.tsx @@ -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(' { diff --git a/web/app/components/base/markdown-blocks/shiki-highlight.tsx b/web/app/components/base/markdown-blocks/shiki-highlight.tsx index cfc075827f4..beae5f80189 100644 --- a/web/app/components/base/markdown-blocks/shiki-highlight.tsx +++ b/web/app/components/base/markdown-blocks/shiki-highlight.tsx @@ -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 => { - 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, }) diff --git a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx index a223d6524b0..876224f4da0 100644 --- a/web/app/components/plugins/marketplace/__tests__/state.spec.tsx +++ b/web/app/components/plugins/marketplace/__tests__/state.spec.tsx @@ -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') diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx index 6500bab8f73..a72746a2168 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -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 ? ( +
+ {plugin.name} +
+ ) : 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() + + expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute( + 'href', + '/plugin/langgenius/dropbox', + ) }) it('clamps the active slide when a refetch shrinks the banner list', async () => { diff --git a/web/app/components/plugins/marketplace/home/home-trending-slides.tsx b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx index 0510bdb6749..57654f390df 100644 --- a/web/app/components/plugins/marketplace/home/home-trending-slides.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending-slides.tsx @@ -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 ( - { - 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', - )} - > + <>
$['marketplace.home.trendingView'])}
+ + ) +} + +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 ( + <> + + + + ) +} + +function TrendingCard({ + banner, + card, + isMarketplacePlatform, + page, +}: { + banner: BannerRecommend + card: BannerRecommendCard + isMarketplacePlatform: boolean + page: MarketplaceBannerPage +}) { + const embeddedPlugin = isMarketplacePlatform ? null : pluginFromRecommendCard(card) + if (embeddedPlugin) { + return ( + + ) + } + + const href = getCardHref(card, isMarketplacePlatform) + if (!href) return null + const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href) + + return ( + { + trackRecommendCardClick(banner, card, page, href) + }} + className={recommendCardClassName} + > + ) } diff --git a/web/app/components/plugins/marketplace/list/__tests__/search-results-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/search-results-layout.browser.spec.tsx new file mode 100644 index 00000000000..259bca8c64e --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/search-results-layout.browser.spec.tsx @@ -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( +
+ ( + + {plugin.name} + + )} + /> +
, + ) + + 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) + } + }) +}) diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index f6d94dddc0c..eec7c9d7577 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -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 && ( -
+
{plugins.map((plugin) => { if (cardRender) return cardRender(plugin) diff --git a/web/app/components/plugins/marketplace/query-options.ts b/web/app/components/plugins/marketplace/query-options.ts index c9a8f5ba8ac..a1351fea7e9 100644 --- a/web/app/components/plugins/marketplace/query-options.ts +++ b/web/app/components/plugins/marketplace/query-options.ts @@ -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