fix: isolate standalone marketplace shared UI

This commit is contained in:
CodingOnStar 2026-07-30 20:09:30 +08:00
parent 3872eeb7f6
commit dbe9309ab8
5 changed files with 195 additions and 28 deletions

View File

@ -0,0 +1,85 @@
import type { CardPayload } from '../index'
import { render } from '@testing-library/react'
import { useAtomValue } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../../types'
import Card from '../index'
vi.mock('jotai', () => ({
useAtomValue: vi.fn(),
}))
vi.mock('@/context/workspace-state', () => ({
currentWorkspaceIdAtom: Symbol('currentWorkspaceIdAtom'),
}))
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string | ((...args: never[]) => unknown)) =>
typeof key === 'string' ? key : 'translated',
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (value: Record<string, string>) => value['en-US'] ?? '',
}))
vi.mock('../../hooks', () => ({
useCategories: () => ({
categoriesMap: {
tool: { label: 'Tool' },
},
}),
}))
const marketplacePlugin = {
badges: [],
brief: { 'en-US': 'Marketplace plugin description' },
category: PluginCategoryEnum.tool,
description: { 'en-US': 'Marketplace plugin description' },
endpoint: { settings: [] },
from: 'marketplace',
icon: 'icon.png',
install_count: 0,
introduction: '',
label: { 'en-US': 'Marketplace plugin' },
latest_package_identifier: 'langgenius/demo-plugin:1.0.0',
latest_version: '1.0.0',
name: 'demo-plugin',
org: 'langgenius',
plugin_id: 'langgenius/demo-plugin',
repository: '',
tags: [],
type: 'plugin',
verified: false,
verification: { authorized_category: 'langgenius' },
version: '1.0.0',
} satisfies CardPayload
describe('Plugin card workspace boundary', () => {
it('renders Marketplace variant icons without reading Dify workspace state', () => {
vi.mocked(useAtomValue).mockImplementation(() => {
throw new Error('Dify workspace state must not be read')
})
const payloadWithoutSource = {
...marketplacePlugin,
from: undefined,
} as unknown as CardPayload
const { container } = render(<Card payload={payloadWithoutSource} variant="marketplace" />)
expect(container.querySelector('[style*="background-image"]')).toHaveStyle({
backgroundImage: `url("${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon")`,
})
expect(useAtomValue).not.toHaveBeenCalled()
})
})

View File

@ -42,6 +42,37 @@ type Props = Readonly<{
variant?: 'default' | 'marketplace'
}>
type CardIconProps = {
icon: CardPayload['icon']
installFailed?: boolean
installed?: boolean
marketplace?: boolean
plugin: Pick<Plugin, 'from' | 'name' | 'org' | 'type'>
}
const WorkspaceCardIcon = ({ icon, installFailed, installed, plugin }: CardIconProps) => {
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const iconSrc = getPluginCardIconUrl(plugin, icon, currentWorkspaceId)
return <Icon src={iconSrc} installed={installed} installFailed={installFailed} />
}
const CardIcon = ({ icon, installFailed, installed, marketplace, plugin }: CardIconProps) => {
if (marketplace || plugin.from === 'marketplace') {
const iconSrc = getPluginCardIconUrl({ ...plugin, from: 'marketplace' }, icon, '')
return <Icon src={iconSrc} installed={installed} installFailed={installFailed} />
}
return (
<WorkspaceCardIcon
icon={icon}
installFailed={installFailed}
installed={installed}
plugin={plugin}
/>
)
}
const Card = ({
className,
payload,
@ -60,15 +91,11 @@ const Card = ({
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload
const badges = payload.badges ?? []
const { theme } = useTheme()
const iconSrc = getPluginCardIconUrl(
{ from, name, org, type },
theme === Theme.dark && icon_dark ? icon_dark : icon,
currentWorkspaceId,
)
const activeIcon = theme === Theme.dark && icon_dark ? icon_dark : icon
const pluginIdentity = { from, name, org, type }
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj ? renderI18nObject(obj, locale) : ''
const isPartner = badges.includes('partner')
@ -92,7 +119,13 @@ const Card = ({
<div className="relative flex h-full flex-col">
{!hideCornerMark && <CornerMark text={cornerMarkText} />}
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
<CardIcon
icon={activeIcon}
installed={installed}
installFailed={installFailed}
marketplace
plugin={pluginIdentity}
/>
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<div className="flex h-5 min-w-0 items-center">
<div className="truncate system-md-medium text-text-primary">
@ -152,7 +185,12 @@ const Card = ({
{!hideCornerMark && <CornerMark text={cornerMarkText} />}
{/* Header */}
<div className="flex">
<Icon src={iconSrc} installed={installed} installFailed={installFailed} />
<CardIcon
icon={activeIcon}
installed={installed}
installFailed={installFailed}
plugin={pluginIdentity}
/>
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<Title title={getLocalizedText(label)} />

View File

@ -1,18 +1,27 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import HomeHeader from '../home-header'
const mocks = vi.hoisted(() => ({
useDocLink: vi.fn(() => () => 'https://docs.dify.ai/en/home'),
}))
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
return {
useTranslation: () => ({
i18n: {
language: 'en-US',
},
t: withSelectorKey((key: string) => key),
}),
}
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => () => 'https://docs.dify.ai/en/home',
defaultDocBaseUrl: 'https://docs.dify.ai',
getDocHomePath: () => '/home',
useDocLink: mocks.useDocLink,
}))
vi.mock('../home-sticky-state-provider', () => ({
@ -20,6 +29,10 @@ vi.mock('../home-sticky-state-provider', () => ({
}))
describe('HomeHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('links the Guide action to Dify documentation', () => {
render(<HomeHeader isMarketplacePlatform />)
@ -40,6 +53,17 @@ describe('HomeHeader', () => {
expect(guideLink).toHaveAttribute('href', 'https://docs.dify.ai/en/home')
expect(guideLink).toHaveAttribute('target', '_blank')
expect(guideLink).toHaveAttribute('rel', 'noopener noreferrer')
expect(mocks.useDocLink).not.toHaveBeenCalled()
})
it('uses the Dify deployment-aware documentation link inside Dify', () => {
render(<HomeHeader isMarketplacePlatform={false} />)
expect(screen.getByRole('link', { name: 'marketplace.home.guide' })).toHaveAttribute(
'href',
'https://docs.dify.ai/en/home',
)
expect(mocks.useDocLink).toHaveBeenCalledOnce()
})
it('shows Templates as the active compact tab on the Templates catalog', () => {

View File

@ -0,0 +1,36 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from '#i18n'
import { defaultDocBaseUrl, getDocHomePath, useDocLink } from '@/context/i18n'
import { getDocLanguage } from '@/i18n-config/language'
import Link from '@/next/link'
import styles from './home-sticky.module.css'
function GuideLink({ href }: { href: string }) {
const { t } = useTranslation('plugin')
return (
<Link href={href} target="_blank" rel="noopener noreferrer" className={styles.guide}>
<Button variant="ghost" size="large" className="min-w-[94px] gap-0.5 px-3 text-text-primary">
<span aria-hidden className="i-ri-map-2-line size-5" />
<span className="px-1 system-md-medium">{t(($) => $['marketplace.home.guide'])}</span>
</Button>
</Link>
)
}
function MarketplaceGuide() {
const { i18n } = useTranslation()
const docLanguage = getDocLanguage(i18n.language)
return <GuideLink href={`${defaultDocBaseUrl}/${docLanguage}${getDocHomePath()}`} />
}
function DifyGuide() {
const docLink = useDocLink()
return <GuideLink href={docLink()} />
}
export default function HomeGuide({ isMarketplacePlatform }: { isMarketplacePlatform: boolean }) {
return isMarketplacePlatform ? <MarketplaceGuide /> : <DifyGuide />
}

View File

@ -1,12 +1,10 @@
import type { HomeCatalogTab } from './home-catalog-tabs'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from '#i18n'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg'
import HomeCatalogTabs from './home-catalog-tabs'
import HomeGuide from './home-guide'
import { HomeStickyCatalogTabs } from './home-sticky-state-provider'
import styles from './home-sticky.module.css'
@ -16,20 +14,6 @@ type HomeHeaderProps = {
isMarketplacePlatform: boolean
}
function Guide() {
const docLink = useDocLink()
const { t } = useTranslation('plugin')
return (
<Link href={docLink()} target="_blank" rel="noopener noreferrer" className={styles.guide}>
<Button variant="ghost" size="large" className="min-w-[94px] gap-0.5 px-3 text-text-primary">
<span aria-hidden className="i-ri-map-2-line size-5" />
<span className="px-1 system-md-medium">{t(($) => $['marketplace.home.guide'])}</span>
</Button>
</Link>
)
}
const HomeHeader = ({ activeTab = 'plugins', actions, isMarketplacePlatform }: HomeHeaderProps) => {
return (
<header
@ -77,7 +61,7 @@ const HomeHeader = ({ activeTab = 'plugins', actions, isMarketplacePlatform }: H
</div>
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-2.5">
<Guide />
<HomeGuide isMarketplacePlatform={isMarketplacePlatform} />
{actions}
</div>
</header>