mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix: isolate standalone marketplace shared UI
This commit is contained in:
parent
3872eeb7f6
commit
dbe9309ab8
85
web/app/components/plugins/card/__tests__/index.spec.tsx
Normal file
85
web/app/components/plugins/card/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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)} />
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
36
web/app/components/plugins/marketplace/home/home-guide.tsx
Normal file
36
web/app/components/plugins/marketplace/home/home-guide.tsx
Normal 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 />
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user