feat: enhance model plugin workflow checks and model provider management UX (#33289)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Signed-off-by: dependabot[bot] <support@github.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: Coding On Star <447357187@qq.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: statxc <tyleradams93226@gmail.com>
This commit is contained in:
yyh
2026-03-18 10:16:15 +08:00
committed by GitHub
parent aa4a9877f5
commit bbe975c6bc
319 changed files with 19582 additions and 5541 deletions

View File

@ -1,7 +1,17 @@
import type { TagKey } from '../constants'
import type { Plugin } from '../types'
import { describe, expect, it } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../types'
import { getValidCategoryKeys, getValidTagKeys } from '../utils'
import { getPluginCardIconUrl, getValidCategoryKeys, getValidTagKeys } from '../utils'
const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({
from: 'github',
name: 'demo-plugin',
org: 'langgenius',
type: 'plugin',
...overrides,
})
describe('plugins/utils', () => {
describe('getValidTagKeys', () => {
@ -47,4 +57,31 @@ describe('plugins/utils', () => {
expect(getValidCategoryKeys('')).toBeUndefined()
})
})
describe('getPluginCardIconUrl', () => {
it('returns an empty string when icon is missing', () => {
expect(getPluginCardIconUrl(createPlugin(), undefined, 'tenant-1')).toBe('')
})
it('returns absolute urls and root-relative urls as-is', () => {
expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe('https://example.com/icon.png')
expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe('/icons/demo.png')
})
it('builds the marketplace icon url for plugins and bundles', () => {
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1'))
.toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`)
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace', type: 'bundle' }), 'icon.png', 'tenant-1'))
.toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`)
})
it('falls back to the raw icon when tenant id is missing for non-marketplace plugins', () => {
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', '')).toBe('icon.png')
})
it('builds the workspace icon url for tenant-scoped plugins', () => {
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1'))
.toBe(`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`)
})
})
})

View File

@ -2,6 +2,7 @@ import type { Plugin } from '../../types'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../../types'
import Card from '../index'
@ -40,6 +41,12 @@ vi.mock('@/utils/format', () => ({
formatNumber: (num: number) => num.toLocaleString(),
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (value: { currentWorkspace: { id: string } }) => string) => selector({
currentWorkspace: { id: 'workspace-123' },
}),
}))
vi.mock('@/utils/mcp', () => ({
shouldUseMcpIcon: (src: unknown) => typeof src === 'object' && src !== null && (src as { content?: string })?.content === '🔗',
}))
@ -189,6 +196,36 @@ describe('Card', () => {
expect(iconElement).toBeInTheDocument()
})
it('should normalize package icon filenames to workspace icon urls', () => {
const plugin = createMockPlugin({
from: 'package',
icon: 'custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({
backgroundImage: `url(${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=custom-icon.png)`,
})
})
it('should normalize marketplace icon filenames to marketplace icon urls', () => {
const plugin = createMockPlugin({
from: 'marketplace',
icon: 'custom-icon.png',
})
const { container } = render(<Card payload={plugin} />)
const iconElement = container.querySelector('[style*="background-image"]')
expect(iconElement).toBeInTheDocument()
expect(iconElement).toHaveStyle({
backgroundImage: `url(${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon)`,
})
})
it('should use icon_dark when theme is dark and icon_dark is provided', () => {
// Set theme to dark
mockTheme = 'dark'

View File

@ -3,6 +3,7 @@ import type { Plugin } from '../types'
import { useTranslation } from '#i18n'
import { RiAlertFill } from '@remixicon/react'
import * as React from 'react'
import { useSelector } from '@/context/app-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import {
@ -14,6 +15,7 @@ import Partner from '../base/badges/partner'
import Verified from '../base/badges/verified'
import Icon from '../card/base/card-icon'
import { useCategories } from '../hooks'
import { getPluginCardIconUrl } from '../utils'
import CornerMark from './base/corner-mark'
import Description from './base/description'
import OrgInfo from './base/org-info'
@ -50,9 +52,14 @@ const Card = ({
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
const currentWorkspaceId = useSelector(s => s.currentWorkspace.id)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [], from } = payload
const { theme } = useTheme()
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
const iconSrc = getPluginCardIconUrl(
{ from, name, org, type },
theme === Theme.dark && icon_dark ? icon_dark : icon,
currentWorkspaceId,
)
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj ? renderI18nObject(obj, locale) : ''
const isPartner = badges.includes('partner')
@ -101,7 +108,7 @@ const Card = ({
&& (
<div className="relative flex h-8 items-center gap-x-2 px-3 after:absolute after:bottom-0 after:left-0 after:right-0 after:top-0 after:bg-toast-warning-bg after:opacity-40">
<RiAlertFill className="h-3 w-3 shrink-0 text-text-warning-secondary" />
<p className="system-xs-regular z-10 grow text-text-secondary">
<p className="z-10 grow text-text-secondary system-xs-regular">
{t('installModal.installWarning', { ns: 'plugin' })}
</p>
</div>

View File

@ -0,0 +1,128 @@
import type { PluginDetail } from './types'
import { useQuery } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { usePluginsWithLatestVersion } from './hooks'
import { PluginSource } from './types'
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
latestVersions: {
queryOptions: vi.fn((options: unknown) => options),
},
},
},
}))
const createPlugin = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'plugin-1',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
name: 'demo-plugin',
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@1.0.0',
declaration: {} as PluginDetail['declaration'],
installation_id: 'installation-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-1@1.0.0',
source: PluginSource.marketplace,
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('usePluginsWithLatestVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useQuery).mockReturnValue({ data: undefined } as never)
})
it('should disable latest-version querying when there are no marketplace plugins', () => {
const plugins = [
createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github }),
]
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: [] } },
enabled: false,
})
expect(result.current).toEqual(plugins)
})
it('should return the original plugins when version data is unavailable', () => {
const plugins = [createPlugin()]
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(result.current).toEqual(plugins)
})
it('should keep plugins unchanged when a plugin has no matching latest version', () => {
const plugins = [createPlugin()]
vi.mocked(useQuery).mockReturnValue({
data: { versions: {} },
} as never)
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(result.current).toEqual(plugins)
})
it('should merge latest version fields for marketplace plugins with version data', () => {
const plugins = [
createPlugin(),
createPlugin({
id: 'plugin-2',
plugin_id: 'plugin-2',
plugin_unique_identifier: 'plugin-2@1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-2@1.0.0',
source: PluginSource.github,
}),
]
vi.mocked(useQuery).mockReturnValue({
data: {
versions: {
'plugin-1': {
version: '1.1.0',
unique_identifier: 'plugin-1@1.1.0',
status: 'deleted',
deprecated_reason: 'replaced',
alternative_plugin_id: 'plugin-3',
},
},
},
} as never)
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: ['plugin-1'] } },
enabled: true,
})
expect(result.current).toEqual([
expect.objectContaining({
plugin_id: 'plugin-1',
latest_version: '1.1.0',
latest_unique_identifier: 'plugin-1@1.1.0',
status: 'deleted',
deprecated_reason: 'replaced',
alternative_plugin_id: 'plugin-3',
}),
plugins[1],
])
})
})

View File

@ -1,11 +1,14 @@
import type { CategoryKey, TagKey } from './constants'
import type { PluginDetail } from './types'
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
categoryKeys,
tagKeys,
} from './constants'
import { PluginCategoryEnum } from './types'
import { PluginCategoryEnum, PluginSource } from './types'
export type Tag = {
name: TagKey
@ -95,3 +98,39 @@ export const usePluginPageTabs = () => {
]
return tabs
}
const EMPTY_PLUGINS: PluginDetail[] = []
export function usePluginsWithLatestVersion(plugins: PluginDetail[] = EMPTY_PLUGINS): PluginDetail[] {
const marketplacePluginIds = useMemo(
() => plugins
.filter(p => p.source === PluginSource.marketplace)
.map(p => p.plugin_id),
[plugins],
)
const { data: latestVersionData } = useQuery(consoleQuery.plugins.latestVersions.queryOptions({
input: { body: { plugin_ids: marketplacePluginIds } },
enabled: !!marketplacePluginIds.length,
}))
return useMemo(() => {
const versions = latestVersionData?.versions
if (!versions)
return plugins
return plugins.map((plugin) => {
const info = versions[plugin.plugin_id]
if (!info)
return plugin
return {
...plugin,
latest_version: info.version,
latest_unique_identifier: info.unique_identifier,
status: info.status,
deprecated_reason: info.deprecated_reason,
alternative_plugin_id: info.alternative_plugin_id,
}
})
}, [plugins, latestVersionData])
}

View File

@ -170,9 +170,13 @@ vi.mock('@/service/use-plugins', () => ({
}))
// Mock config
vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/config', async () => {
const actual = await vi.importActual<typeof import('@/config')>('@/config')
return {
...actual,
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}
})
// Mock mitt context
vi.mock('@/context/mitt-context', () => ({

View File

@ -266,10 +266,117 @@ describe('useInstallMultiState', () => {
expect(result.current.plugins[1]).toBeDefined()
})
})
it('should fall back to latest_version when marketplace plugin version is missing', async () => {
mockMarketplaceData = {
data: {
list: [{
plugin: {
plugin_id: 'test-org/plugin-0',
org: 'test-org',
name: 'Test Plugin 0',
version: '',
latest_version: '2.0.0',
},
version: {
unique_identifier: 'plugin-0-uid',
},
}],
},
}
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]?.version).toBe('2.0.0')
})
})
it('should resolve marketplace dependency from organization and plugin fields', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
organization: 'test-org',
plugin: 'plugin-0',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]).toBeDefined()
expect(result.current.errorIndexes).not.toContain(0)
})
})
})
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should mark marketplace index as error when identifier misses plugin and version parts', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'invalid-identifier',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier has an empty plugin segment', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'test-org/:1.0.0',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier is missing', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark all marketplace indexes as errors on fetch failure', async () => {
mockMarketplaceError = new Error('Fetch failed')
@ -303,6 +410,19 @@ describe('useInstallMultiState', () => {
expect(result.current.errorIndexes).not.toContain(0)
})
})
it('should ignore marketplace requests whose dsl index cannot be mapped', () => {
const duplicatedMarketplaceDependency = createMarketplaceDependency(0)
const allPlugins = [duplicatedMarketplaceDependency] as Dependency[]
allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter
const params = createDefaultParams({ allPlugins })
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(1)
expect(result.current.errorIndexes).toEqual([])
})
})
// ==================== Loaded All Data Notification ====================

View File

@ -14,14 +14,55 @@ type UseInstallMultiStateParams = {
onLoadedAllPlugin: (installedInfo: Record<string, VersionInfo>) => void
}
type MarketplacePluginInfo = {
organization: string
plugin: string
version?: string
}
type MarketplaceRequest = {
dslIndex: number
dependency: GitHubItemAndMarketPlaceDependency
info: MarketplacePluginInfo
}
export function getPluginKey(plugin: Plugin | undefined): string {
return `${plugin?.org || plugin?.author}/${plugin?.name}`
}
function parseMarketplaceIdentifier(identifier: string) {
const [orgPart, nameAndVersionPart] = identifier.split('@')[0].split('/')
const [name, version] = nameAndVersionPart.split(':')
return { organization: orgPart, plugin: name, version }
function parseMarketplaceIdentifier(identifier?: string): MarketplacePluginInfo | null {
if (!identifier)
return null
const withoutHash = identifier.split('@')[0]
const [organization, nameAndVersionPart] = withoutHash.split('/')
if (!organization || !nameAndVersionPart)
return null
const [plugin, version] = nameAndVersionPart.split(':')
if (!plugin)
return null
return { organization, plugin, version }
}
function getMarketplacePluginInfo(
value: GitHubItemAndMarketPlaceDependency['value'],
): MarketplacePluginInfo | null {
const parsedInfo = parseMarketplaceIdentifier(
value.marketplace_plugin_unique_identifier || value.plugin_unique_identifier,
)
if (parsedInfo)
return parsedInfo
if (!value.organization || !value.plugin)
return null
return {
organization: value.organization,
plugin: value.plugin,
version: value.version,
}
}
function initPluginsFromDependencies(allPlugins: Dependency[]): (Plugin | undefined)[] {
@ -61,67 +102,73 @@ export function useInstallMultiState({
}, [])
}, [allPlugins])
// Marketplace data fetching: by unique identifier and by meta info
const { marketplaceRequests, invalidMarketplaceIndexes } = useMemo(() => {
return marketplacePlugins.reduce<{
marketplaceRequests: MarketplaceRequest[]
invalidMarketplaceIndexes: number[]
}>((acc, dependency, marketplaceIndex) => {
const dslIndex = marketPlaceInDSLIndex[marketplaceIndex]
if (dslIndex === undefined)
return acc
const marketplaceInfo = getMarketplacePluginInfo(dependency.value)
if (!marketplaceInfo)
acc.invalidMarketplaceIndexes.push(dslIndex)
else
acc.marketplaceRequests.push({ dslIndex, dependency, info: marketplaceInfo })
return acc
}, {
marketplaceRequests: [],
invalidMarketplaceIndexes: [],
})
}, [marketPlaceInDSLIndex, marketplacePlugins])
// Marketplace data fetching: by normalized marketplace info
const {
isLoading: isFetchingById,
data: infoGetById,
error: infoByIdError,
} = useFetchPluginsInMarketPlaceByInfo(
marketplacePlugins.map(d => parseMarketplaceIdentifier(d.value.marketplace_plugin_unique_identifier!)),
)
const {
isLoading: isFetchingByMeta,
data: infoByMeta,
error: infoByMetaError,
} = useFetchPluginsInMarketPlaceByInfo(
marketplacePlugins.map(d => d.value!),
marketplaceRequests.map(request => request.info),
)
// Derive marketplace plugin data and errors from API responses
const { marketplacePluginMap, marketplaceErrorIndexes } = useMemo(() => {
const pluginMap = new Map<number, Plugin>()
const errorSet = new Set<number>()
const errorSet = new Set<number>(invalidMarketplaceIndexes)
// Process "by ID" response
if (!isFetchingById && infoGetById?.data.list) {
const sortedList = marketplacePlugins.map((d) => {
const id = d.value.marketplace_plugin_unique_identifier?.split(':')[0]
const retPluginInfo = infoGetById.data.list.find(item => item.plugin.plugin_id === id)?.plugin
return { ...retPluginInfo, from: d.type } as Plugin
})
marketPlaceInDSLIndex.forEach((index, i) => {
if (sortedList[i]) {
pluginMap.set(index, {
...sortedList[i],
version: sortedList[i]!.version || sortedList[i]!.latest_version,
const payloads = infoGetById.data.list
const pluginById = new Map(
payloads.map(item => [item.plugin.plugin_id, item.plugin]),
)
marketplaceRequests.forEach((request, requestIndex) => {
const pluginId = (
request.dependency.value.marketplace_plugin_unique_identifier
|| request.dependency.value.plugin_unique_identifier
)?.split(':')[0]
const pluginInfo = (pluginId ? pluginById.get(pluginId) : undefined) || payloads[requestIndex]?.plugin
if (pluginInfo) {
pluginMap.set(request.dslIndex, {
...pluginInfo,
from: request.dependency.type,
version: pluginInfo.version || pluginInfo.latest_version,
})
}
else { errorSet.add(index) }
})
}
// Process "by meta" response (may overwrite "by ID" results)
if (!isFetchingByMeta && infoByMeta?.data.list) {
const payloads = infoByMeta.data.list
marketPlaceInDSLIndex.forEach((index, i) => {
if (payloads[i]) {
const item = payloads[i]
pluginMap.set(index, {
...item.plugin,
plugin_id: item.version.unique_identifier,
} as Plugin)
}
else { errorSet.add(index) }
else { errorSet.add(request.dslIndex) }
})
}
// Mark all marketplace indexes as errors on fetch failure
if (infoByMetaError || infoByIdError)
if (infoByIdError)
marketPlaceInDSLIndex.forEach(index => errorSet.add(index))
return { marketplacePluginMap: pluginMap, marketplaceErrorIndexes: errorSet }
}, [isFetchingById, isFetchingByMeta, infoGetById, infoByMeta, infoByMetaError, infoByIdError, marketPlaceInDSLIndex, marketplacePlugins])
}, [invalidMarketplaceIndexes, isFetchingById, infoGetById, infoByIdError, marketPlaceInDSLIndex, marketplaceRequests])
// GitHub-fetched plugins and errors (imperative state from child callbacks)
const [githubPluginMap, setGithubPluginMap] = useState<Map<number, Plugin>>(() => new Map())

View File

@ -3,6 +3,16 @@ import { act, renderHook } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import {
useActivePluginType,
useFilterPluginTags,
useMarketplaceMoreClick,
useMarketplaceSearchMode,
useMarketplaceSort,
useMarketplaceSortValue,
useSearchPluginText,
useSetMarketplaceSort,
} from '../atoms'
import { DEFAULT_SORT } from '../constants'
const createWrapper = (searchParams = '') => {
@ -22,8 +32,7 @@ describe('Marketplace sort atoms', () => {
vi.clearAllMocks()
})
it('should return default sort value from useMarketplaceSort', async () => {
const { useMarketplaceSort } = await import('../atoms')
it('should return default sort value from useMarketplaceSort', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
@ -31,24 +40,28 @@ describe('Marketplace sort atoms', () => {
expect(typeof result.current[1]).toBe('function')
})
it('should return default sort value from useMarketplaceSortValue', async () => {
const { useMarketplaceSortValue } = await import('../atoms')
it('should return default sort value from useMarketplaceSortValue', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSortValue(), { wrapper })
expect(result.current).toEqual(DEFAULT_SORT)
})
it('should return setter from useSetMarketplaceSort', async () => {
const { useSetMarketplaceSort } = await import('../atoms')
it('should return setter from useSetMarketplaceSort', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSetMarketplaceSort(), { wrapper })
const { result } = renderHook(() => ({
setSort: useSetMarketplaceSort(),
sortValue: useMarketplaceSortValue(),
}), { wrapper })
expect(typeof result.current).toBe('function')
act(() => {
result.current.setSort({ sortBy: 'created_at', sortOrder: 'ASC' })
})
expect(result.current.sortValue).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
it('should update sort value via useMarketplaceSort setter', async () => {
const { useMarketplaceSort } = await import('../atoms')
it('should update sort value via useMarketplaceSort setter', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })
@ -65,8 +78,7 @@ describe('useSearchPluginText', () => {
vi.clearAllMocks()
})
it('should return empty string as default', async () => {
const { useSearchPluginText } = await import('../atoms')
it('should return empty string as default', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
@ -74,8 +86,7 @@ describe('useSearchPluginText', () => {
expect(typeof result.current[1]).toBe('function')
})
it('should parse q from search params', async () => {
const { useSearchPluginText } = await import('../atoms')
it('should parse q from search params', () => {
const { wrapper } = createWrapper('?q=hello')
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
@ -83,16 +94,14 @@ describe('useSearchPluginText', () => {
})
it('should expose a setter function for search text', async () => {
const { useSearchPluginText } = await import('../atoms')
const { wrapper } = createWrapper()
const { result } = renderHook(() => useSearchPluginText(), { wrapper })
expect(typeof result.current[1]).toBe('function')
// Calling the setter should not throw
await act(async () => {
result.current[1]('search term')
})
expect(result.current[0]).toBe('search term')
})
})
@ -101,16 +110,14 @@ describe('useActivePluginType', () => {
vi.clearAllMocks()
})
it('should return "all" as default category', async () => {
const { useActivePluginType } = await import('../atoms')
it('should return "all" as default category', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useActivePluginType(), { wrapper })
expect(result.current[0]).toBe('all')
})
it('should parse category from search params', async () => {
const { useActivePluginType } = await import('../atoms')
it('should parse category from search params', () => {
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useActivePluginType(), { wrapper })
@ -123,16 +130,14 @@ describe('useFilterPluginTags', () => {
vi.clearAllMocks()
})
it('should return empty array as default', async () => {
const { useFilterPluginTags } = await import('../atoms')
it('should return empty array as default', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
expect(result.current[0]).toEqual([])
})
it('should parse tags from search params', async () => {
const { useFilterPluginTags } = await import('../atoms')
it('should parse tags from search params', () => {
const { wrapper } = createWrapper('?tags=search')
const { result } = renderHook(() => useFilterPluginTags(), { wrapper })
@ -145,42 +150,35 @@ describe('useMarketplaceSearchMode', () => {
vi.clearAllMocks()
})
it('should return false when no search text, no tags, and category has collections (all)', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
it('should return false when no search text, no tags, and category has collections (all)', () => {
const { wrapper } = createWrapper('?category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
// "all" is in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode should be false
expect(result.current).toBe(false)
})
it('should return true when search text is present', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
it('should return true when search text is present', () => {
const { wrapper } = createWrapper('?q=test&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
})
it('should return true when tags are present', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
it('should return true when tags are present', () => {
const { wrapper } = createWrapper('?tags=search&category=all')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
expect(result.current).toBe(true)
})
it('should return true when category does not have collections (e.g. model)', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
it('should return true when category does not have collections (e.g. model)', () => {
const { wrapper } = createWrapper('?category=model')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
// "model" is NOT in PLUGIN_CATEGORY_WITH_COLLECTIONS, so search mode = true
expect(result.current).toBe(true)
})
it('should return false when category has collections (tool) and no search/tags', async () => {
const { useMarketplaceSearchMode } = await import('../atoms')
it('should return false when category has collections (tool) and no search/tags', () => {
const { wrapper } = createWrapper('?category=tool')
const { result } = renderHook(() => useMarketplaceSearchMode(), { wrapper })
@ -193,27 +191,33 @@ describe('useMarketplaceMoreClick', () => {
vi.clearAllMocks()
})
it('should return a callback function', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
it('should return a callback function', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
expect(typeof result.current).toBe('function')
})
it('should do nothing when called with no params', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
it('should do nothing when called with no params', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
searchText: useSearchPluginText()[0],
}), { wrapper })
const sortBefore = result.current.sort
const searchTextBefore = result.current.searchText
// Should not throw when called with undefined
act(() => {
result.current(undefined)
result.current.handleMoreClick(undefined)
})
expect(result.current.sort).toEqual(sortBefore)
expect(result.current.searchText).toBe(searchTextBefore)
})
it('should update search state when called with search params', async () => {
const { useMarketplaceMoreClick, useMarketplaceSortValue } = await import('../atoms')
it('should update search state when called with search params', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
@ -229,17 +233,20 @@ describe('useMarketplaceMoreClick', () => {
})
})
// Sort should be updated via the jotai atom
expect(result.current.sort).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
it('should use defaults when search params fields are missing', async () => {
const { useMarketplaceMoreClick } = await import('../atoms')
it('should use defaults when search params fields are missing', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceMoreClick(), { wrapper })
const { result } = renderHook(() => ({
handleMoreClick: useMarketplaceMoreClick(),
sort: useMarketplaceSortValue(),
}), { wrapper })
act(() => {
result.current({})
result.current.handleMoreClick({})
})
expect(result.current.sort).toEqual(DEFAULT_SORT)
})
})

View File

@ -74,31 +74,40 @@ describe('PluginTypeSwitch', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on Models option — should not throw
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
fireEvent.click(screen.getByText('Models'))
const modelsButton = screen.getByText('Models').closest('div')
expect(modelsButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
it('should handle clicking on category with collections (Tools)', () => {
const { Wrapper } = createWrapper('?category=model')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on "Tools" which has collections → setSearchMode(null)
expect(() => fireEvent.click(screen.getByText('Tools'))).not.toThrow()
fireEvent.click(screen.getByText('Tools'))
const toolsButton = screen.getByText('Tools').closest('div')
expect(toolsButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
it('should handle clicking on category without collections (Models)', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
// Click on "Models" which does NOT have collections → no setSearchMode call
expect(() => fireEvent.click(screen.getByText('Models'))).not.toThrow()
fireEvent.click(screen.getByText('Models'))
const modelsButton = screen.getByText('Models').closest('div')
expect(modelsButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
it('should handle clicking on bundles', () => {
const { Wrapper } = createWrapper('?category=all')
render(<PluginTypeSwitch />, { wrapper: Wrapper })
expect(() => fireEvent.click(screen.getByText('Bundles'))).not.toThrow()
fireEvent.click(screen.getByText('Bundles'))
const bundlesButton = screen.getByText('Bundles').closest('div')
expect(bundlesButton?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
it('should handle clicking on each category', () => {
@ -107,7 +116,10 @@ describe('PluginTypeSwitch', () => {
const categories = ['All', 'Models', 'Tools', 'Data Sources', 'Triggers', 'Agents', 'Extensions', 'Bundles']
categories.forEach((category) => {
expect(() => fireEvent.click(screen.getByText(category))).not.toThrow()
fireEvent.click(screen.getByText(category))
const button = screen.getByText(category).closest('div')
expect(button?.className).toContain('!bg-components-main-nav-nav-button-bg-active')
})
})

View File

@ -234,6 +234,24 @@ describe('getMarketplacePluginsByCollectionId', () => {
expect(result).toEqual([])
})
it('should send an empty body when query is omitted', async () => {
mockCollectionPlugins.mockResolvedValueOnce({
data: { plugins: [] },
})
const { getMarketplacePluginsByCollectionId } = await import('../utils')
await getMarketplacePluginsByCollectionId('test-collection')
expect(mockCollectionPlugins).toHaveBeenCalledWith({
params: {
collectionId: 'test-collection',
},
body: {},
}, expect.objectContaining({
signal: undefined,
}))
})
it('should pass abort signal when provided', async () => {
const mockPlugins = [{ type: 'plugin', org: 'test', name: 'plugin1' }]
mockCollectionPlugins.mockResolvedValueOnce({

View File

@ -63,7 +63,7 @@ export const getMarketplacePluginsByCollectionId = async (
params: {
collectionId,
},
body: query,
body: query ?? {},
}, {
signal: options?.signal,
})

View File

@ -249,7 +249,7 @@ const Authorized = ({
!!oAuthCredentials.length && (
<div className="p-1">
<div className={cn(
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium',
showItemSelectedIcon && 'pl-7',
)}
>
@ -279,7 +279,7 @@ const Authorized = ({
!!apiKeyCredentials.length && (
<div className="p-1">
<div className={cn(
'system-xs-medium px-3 pb-0.5 pt-1 text-text-tertiary',
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium',
showItemSelectedIcon && 'pl-7',
)}
>

View File

@ -79,6 +79,10 @@ vi.mock('@/service/plugins', () => ({
uninstallPlugin: mockUninstallPlugin,
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateCheckInstalled: () => vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({ data: [] }),
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
@ -218,23 +222,6 @@ vi.mock('../../plugin-auth', () => ({
PluginAuth: () => <div data-testid="plugin-auth" />,
}))
// Mock Confirm component
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onCancel, onConfirm, isLoading }: {
isShow: boolean
onCancel: () => void
onConfirm: () => void
isLoading: boolean
}) => isShow
? (
<div data-testid="delete-confirm">
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
</div>
)
: null,
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
@ -801,7 +788,7 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
})
@ -810,13 +797,13 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-cancel'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => {
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
@ -825,10 +812,10 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockUninstallPlugin).toHaveBeenCalledWith('test-id')
@ -840,10 +827,10 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalledWith(true)
@ -861,10 +848,10 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockRefreshModelProviders).toHaveBeenCalled()
@ -876,10 +863,10 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockInvalidateAllToolProviders).toHaveBeenCalled()
@ -891,10 +878,10 @@ describe('DetailHeader', () => {
fireEvent.click(screen.getByTestId('remove-btn'))
await waitFor(() => {
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.any(Object))

View File

@ -1,4 +1,6 @@
import type { ReactElement, ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { cloneElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../../types'
import OperationDropdown from '../operation-dropdown'
@ -12,24 +14,22 @@ vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
<button data-testid="action-button" className={className} onClick={onClick}>
{children}
</button>
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
DropdownMenu: ({ children, open }: { children: ReactNode, open: boolean }) => (
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => (
<button data-testid="dropdown-trigger" className={className}>{children}</button>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
<div data-testid="dropdown-content">{children}</div>
),
DropdownMenuItem: ({ children, onClick, render, destructive }: { children: ReactNode, onClick?: () => void, render?: ReactElement, destructive?: boolean }) => {
if (render)
return cloneElement(render, { onClick, 'data-destructive': destructive } as Record<string, unknown>, children)
return <div data-testid="dropdown-item" data-destructive={destructive} onClick={onClick}>{children}</div>
},
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
}))
describe('OperationDropdown', () => {
@ -52,14 +52,13 @@ describe('OperationDropdown', () => {
it('should render trigger button', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByTestId('action-button')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-trigger')).toBeInTheDocument()
})
it('should render dropdown content', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-content')).toBeInTheDocument()
})
it('should render info option for github source', () => {
@ -118,14 +117,10 @@ describe('OperationDropdown', () => {
})
describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => {
it('should render dropdown menu root', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// The portal-elem should reflect the open state
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
})
it('should call onInfo when info option is clicked', () => {
@ -174,7 +169,7 @@ describe('OperationDropdown', () => {
const { unmount } = render(
<OperationDropdown {...defaultProps} source={source} />,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu')).toBeInTheDocument()
expect(screen.getByText('plugin.detailPanel.operation.remove')).toBeInTheDocument()
unmount()
})
@ -199,9 +194,7 @@ describe('OperationDropdown', () => {
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Verify the component is exported as a memo component
expect(OperationDropdown).toBeDefined()
// React.memo wraps the component, so it should have $$typeof
expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})

View File

@ -9,24 +9,6 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, title, onCancel, onConfirm, isLoading }: {
isShow: boolean
title: string
onCancel: () => void
onConfirm: () => void
isLoading: boolean
}) => isShow
? (
<div data-testid="delete-confirm">
<div data-testid="delete-title">{title}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button>
</div>
)
: null,
}))
vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({
default: ({ repository, release, packageName, onHide }: {
repository: string
@ -230,7 +212,7 @@ describe('HeaderModals', () => {
/>,
)
expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('should render delete confirm when isShowDeleteConfirm is true', () => {
@ -247,7 +229,7 @@ describe('HeaderModals', () => {
/>,
)
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
})
it('should show correct delete title', () => {
@ -264,7 +246,7 @@ describe('HeaderModals', () => {
/>,
)
expect(screen.getByTestId('delete-title')).toHaveTextContent('plugin.action.delete')
expect(screen.getByRole('alertdialog')).toHaveTextContent('plugin.action.delete')
})
it('should call hideDeleteConfirm when cancel is clicked', () => {
@ -281,7 +263,7 @@ describe('HeaderModals', () => {
/>,
)
fireEvent.click(screen.getByTestId('confirm-cancel'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(modalStates.hideDeleteConfirm).toHaveBeenCalled()
})
@ -300,7 +282,7 @@ describe('HeaderModals', () => {
/>,
)
fireEvent.click(screen.getByTestId('confirm-ok'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockOnDelete).toHaveBeenCalled()
})
@ -319,7 +301,7 @@ describe('HeaderModals', () => {
/>,
)
expect(screen.getByTestId('confirm-ok')).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.confirm/ })).toBeDisabled()
})
})
@ -485,7 +467,7 @@ describe('HeaderModals', () => {
)
expect(screen.getByTestId('plugin-info')).toBeInTheDocument()
expect(screen.getByTestId('delete-confirm')).toBeInTheDocument()
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
expect(screen.getByTestId('update-modal')).toBeInTheDocument()
})
})

View File

@ -4,7 +4,15 @@ import type { FC } from 'react'
import type { PluginDetail } from '../../../types'
import type { ModalStates, VersionTarget } from '../hooks'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info'
import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place'
import { useGetLanguage } from '@/context/i18n'
@ -50,7 +58,6 @@ const HeaderModals: FC<HeaderModalsProps> = ({
return (
<>
{/* Plugin Info Modal */}
{isShowPluginInfo && (
<PluginInfo
repository={isFromGitHub ? meta?.repo : ''}
@ -60,27 +67,35 @@ const HeaderModals: FC<HeaderModalsProps> = ({
/>
)}
{/* Delete Confirm Modal */}
{isShowDeleteConfirm && (
<Confirm
isShow
title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
content={(
<div>
<AlertDialog
open={isShowDeleteConfirm}
onOpenChange={(open) => {
if (!open)
hideDeleteConfirm()
}}
>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{t(`${i18nPrefix}.delete`, { ns: 'plugin' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
{t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })}
<span className="system-md-semibold">{label[locale]}</span>
<span className="text-text-secondary system-md-semibold">{label[locale]}</span>
{t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })}
<br />
</div>
)}
onCancel={hideDeleteConfirm}
onConfirm={onDelete}
isLoading={deleting}
isDisabled={deleting}
/>
)}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={deleting}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={onDelete}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{/* Update from Marketplace Modal */}
{isShowUpdateModal && (
<UpdateFromMarketplace
pluginId={detail.plugin_id}

View File

@ -15,6 +15,7 @@ type VersionPickerMock = {
const {
mockSetShowUpdatePluginModal,
mockRefreshModelProviders,
mockInvalidateCheckInstalled,
mockInvalidateAllToolProviders,
mockUninstallPlugin,
mockFetchReleases,
@ -23,6 +24,7 @@ const {
return {
mockSetShowUpdatePluginModal: vi.fn(),
mockRefreshModelProviders: vi.fn(),
mockInvalidateCheckInstalled: vi.fn(),
mockInvalidateAllToolProviders: vi.fn(),
mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })),
mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])),
@ -46,6 +48,10 @@ vi.mock('@/service/plugins', () => ({
uninstallPlugin: mockUninstallPlugin,
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateCheckInstalled: () => mockInvalidateCheckInstalled,
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
@ -178,6 +184,7 @@ describe('usePluginOperations', () => {
result.current.handleUpdatedFromMarketplace()
})
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
expect(mockOnUpdate).toHaveBeenCalled()
expect(modalStates.hideUpdateModal).toHaveBeenCalled()
})
@ -251,6 +258,32 @@ describe('usePluginOperations', () => {
expect(mockSetShowUpdatePluginModal).toHaveBeenCalled()
})
it('should invalidate checkInstalled when GitHub update save callback fires', async () => {
const detail = createPluginDetail({
source: PluginSource.github,
meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' },
})
const { result } = renderHook(() =>
usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace: false,
onUpdate: mockOnUpdate,
}),
)
await act(async () => {
await result.current.handleUpdate()
})
const firstCall = mockSetShowUpdatePluginModal.mock.calls.at(0)?.[0]
firstCall?.onSaveCallback()
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
expect(mockOnUpdate).toHaveBeenCalled()
})
it('should not show modal when no releases found', async () => {
mockFetchReleases.mockResolvedValueOnce([])
const detail = createPluginDetail({
@ -388,6 +421,7 @@ describe('usePluginOperations', () => {
await result.current.handleDelete()
})
expect(mockInvalidateCheckInstalled).toHaveBeenCalled()
expect(mockOnUpdate).toHaveBeenCalledWith(true)
})

View File

@ -3,11 +3,13 @@
import type { PluginDetail } from '../../../types'
import type { ModalStates, VersionTarget } from './use-detail-header-state'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Toast from '@/app/components/base/toast'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateCheckInstalled } from '@/service/use-plugins'
import { useInvalidateAllToolProviders } from '@/service/use-tools'
import { useGitHubReleases } from '../../../install-plugin/hooks'
import { PluginCategoryEnum, PluginSource } from '../../../types'
@ -36,13 +38,19 @@ export const usePluginOperations = ({
isFromMarketplace,
onUpdate,
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
const { t } = useTranslation()
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
const invalidateCheckInstalled = useInvalidateCheckInstalled()
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const { id, meta, plugin_id } = detail
const { author, category, name } = detail.declaration || detail
const handlePluginUpdated = useCallback((isDelete?: boolean) => {
invalidateCheckInstalled()
onUpdate?.(isDelete)
}, [invalidateCheckInstalled, onUpdate])
const handleUpdate = useCallback(async (isDowngrade?: boolean) => {
if (isFromMarketplace) {
@ -71,7 +79,7 @@ export const usePluginOperations = ({
if (needUpdate) {
setShowUpdatePluginModal({
onSaveCallback: () => {
onUpdate?.()
handlePluginUpdated()
},
payload: {
type: PluginSource.github,
@ -97,15 +105,15 @@ export const usePluginOperations = ({
checkForUpdates,
setShowUpdatePluginModal,
detail,
onUpdate,
handlePluginUpdated,
modalStates,
versionPicker,
])
const handleUpdatedFromMarketplace = useCallback(() => {
onUpdate?.()
handlePluginUpdated()
modalStates.hideUpdateModal()
}, [onUpdate, modalStates])
}, [handlePluginUpdated, modalStates])
const handleDelete = useCallback(async () => {
modalStates.showDeleting()
@ -114,7 +122,11 @@ export const usePluginOperations = ({
if (res.success) {
modalStates.hideDeleteConfirm()
onUpdate?.(true)
Toast.notify({
type: 'success',
message: t('action.deleteSuccess', { ns: 'plugin' }),
})
handlePluginUpdated(true)
if (PluginCategoryEnum.model.includes(category))
refreshModelProviders()
@ -130,7 +142,7 @@ export const usePluginOperations = ({
plugin_id,
name,
modalStates,
onUpdate,
handlePluginUpdated,
refreshModelProviders,
invalidateAllToolProviders,
])

View File

@ -1,16 +1,12 @@
'use client'
import type { PluginDetail } from '../../types'
import {
RiArrowLeftRightLine,
RiCloseLine,
} from '@remixicon/react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth'
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
@ -180,7 +176,7 @@ const DetailHeader = ({
text={(
<>
<div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div>
{isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />}
{isFromMarketplace && !isReadmeView && <span aria-hidden className="i-ri-arrow-left-right-line ml-1 h-3 w-3 text-text-tertiary" />}
</>
)}
hasRedCornerMark={hasNewVersion}
@ -191,25 +187,43 @@ const DetailHeader = ({
{/* Auto Update Badge */}
{isAutoUpgradeEnabled && !isReadmeView && (
<Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
<div>
<Badge className="mr-1 cursor-pointer px-1">
<AutoUpdateLine className="size-3" />
</Badge>
</div>
<Tooltip>
<TooltipTrigger
delay={0}
render={(
<div>
<Badge className="mr-1 cursor-pointer px-1">
<AutoUpdateLine className="size-3" />
</Badge>
</div>
)}
/>
<TooltipContent>
{t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}
</TooltipContent>
</Tooltip>
)}
{/* Update Button */}
{(hasNewVersion || isFromGitHub) && (
<Button
variant="secondary-accent"
size="small"
className="!h-5"
onClick={handleTriggerLatestUpdate}
>
{t('detailPanel.operation.update', { ns: 'plugin' })}
</Button>
<Tooltip>
<TooltipTrigger
delay={300}
render={(
<Button
variant="secondary-accent"
size="small"
className="!h-5"
onClick={handleTriggerLatestUpdate}
>
{t('detailPanel.operation.update', { ns: 'plugin' })}
</Button>
)}
/>
<TooltipContent>
{t('detailPanel.operation.updateTooltip', { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)}
</div>
@ -237,7 +251,7 @@ const DetailHeader = ({
detailUrl={detailUrl}
/>
<ActionButton onClick={onHide}>
<RiCloseLine className="h-4 w-4" />
<span aria-hidden className="i-ri-close-line h-4 w-4" />
</ActionButton>
</div>
)}

View File

@ -9,40 +9,6 @@ import ModelParameterModal from '../index'
// ==================== Mock Setup ====================
// Mock shared state for portal
let mockPortalOpenState = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpenState = open || false
return (
<div data-testid="portal-elem" data-open={open}>
{children}
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick: () => void, className?: string }) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
if (!mockPortalOpenState)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
},
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock provider context
const mockProviderContextValue = {
isAPIKeySet: true,
@ -87,6 +53,8 @@ vi.mock('@/utils/completion-params', () => ({
fetchAndMergeValidCompletionParams: (...args: unknown[]) => mockFetchAndMergeValidCompletionParams(...args),
}))
const mockToastNotify = vi.spyOn(Toast, 'notify')
// Mock child components
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel, modelList, scopeFeatures, onSelect }: {
@ -108,30 +76,33 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger', () => ({
default: ({ disabled, hasDeprecated, modelDisabled, currentProvider, currentModel, providerName, modelId, isInWorkflow }: {
disabled?: boolean
hasDeprecated?: boolean
modelDisabled?: boolean
default: ({ currentProvider, currentModel, providerName, modelId, isInWorkflow }: {
currentProvider?: Model
currentModel?: ModelItem
providerName?: string
modelId?: string
isInWorkflow?: boolean
}) => (
<div
data-testid="trigger"
data-disabled={disabled}
data-has-deprecated={hasDeprecated}
data-model-disabled={modelDisabled}
data-provider={providerName}
data-model={modelId}
data-in-workflow={isInWorkflow}
data-has-current-provider={!!currentProvider}
data-has-current-model={!!currentModel}
>
Trigger
</div>
),
}) => {
const hasDeprecated = !currentProvider || !currentModel
const modelDisabled = currentModel?.status !== ModelStatusEnum.active
const disabled = !mockProviderContextValue.isAPIKeySet || hasDeprecated || modelDisabled
return (
<div
data-testid="trigger"
data-disabled={disabled}
data-has-deprecated={hasDeprecated}
data-model-disabled={modelDisabled}
data-provider={providerName}
data-model={modelId}
data-in-workflow={isInWorkflow}
data-has-current-provider={!!currentProvider}
data-has-current-model={!!currentModel}
>
Trigger
</div>
)
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger', () => ({
@ -273,7 +244,7 @@ const setupModelLists = (config: {
describe('ModelParameterModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
mockToastNotify.mockReturnValue({})
mockProviderContextValue.isAPIKeySet = true
mockProviderContextValue.modelProviders = []
setupModelLists()
@ -356,7 +327,7 @@ describe('ModelParameterModal', () => {
render(<ModelParameterModal {...props} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
it('should render model selector inside portal content when open', async () => {
@ -365,13 +336,12 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})
@ -405,12 +375,11 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
const content = screen.getByTestId('portal-content')
expect(content.querySelector('.custom-popup-class')).toBeInTheDocument()
expect(document.querySelector('.custom-popup-class')).toBeInTheDocument()
})
})
@ -422,7 +391,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
const selector = screen.getByTestId('model-selector')
@ -438,13 +407,13 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})
@ -454,15 +423,15 @@ describe('ModelParameterModal', () => {
// Act
const { rerender } = render(<ModelParameterModal {...props} />)
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Force a re-render to ensure state is stable
rerender(<ModelParameterModal {...props} />)
// Assert - open state should remain false due to readonly
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
})
@ -474,7 +443,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -489,7 +458,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -512,7 +481,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -530,7 +499,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -547,7 +516,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -564,7 +533,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -581,7 +550,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -598,7 +567,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -615,7 +584,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -632,7 +601,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -831,7 +800,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
fireEvent.click(screen.getByTestId('model-selector'))
@ -856,7 +825,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
fireEvent.click(screen.getByTestId('model-selector'))
@ -888,7 +857,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
fireEvent.click(screen.getByTestId('model-selector'))
@ -915,7 +884,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
fireEvent.click(screen.getByTestId('model-selector'))
@ -951,7 +920,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
const panel = screen.getByTestId('llm-params-panel')
@ -988,7 +957,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
const panel = screen.getByTestId('tts-params-panel')
@ -1025,7 +994,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -1051,7 +1020,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -1077,7 +1046,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -1104,12 +1073,11 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
const content = screen.getByTestId('portal-content')
expect(content.querySelector('.bg-divider-subtle')).toBeInTheDocument()
expect(document.querySelector('.bg-divider-subtle')).toBeInTheDocument()
})
})
})
@ -1146,7 +1114,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -1185,7 +1153,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -1264,7 +1232,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert
await waitFor(() => {
@ -1280,7 +1248,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert - defaultModel is created with undefined provider
await waitFor(() => {
@ -1297,7 +1265,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert - defaultModel is created with undefined model
await waitFor(() => {
@ -1314,7 +1282,7 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
// Assert - when defaultModel is undefined, attribute is not set (returns null)
await waitFor(() => {
@ -1350,14 +1318,13 @@ describe('ModelParameterModal', () => {
// Act
const { rerender } = render(<ModelParameterModal {...props} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByTestId('trigger'))
await waitFor(() => {
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model-list-count', '1')
})
// Rerender with different scope
mockPortalOpenState = true
rerender(<ModelParameterModal {...props} scope={ModelTypeEnum.textEmbedding} />)
// Assert
@ -1398,7 +1365,7 @@ describe('ModelParameterModal', () => {
render(<ModelParameterModal {...props} />)
// Assert
const trigger = screen.getByTestId('portal-trigger')
const trigger = screen.getByTestId('trigger')
expect(trigger).toBeInTheDocument()
})
})

View File

@ -10,12 +10,12 @@ import type {
import type { TriggerProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Toast from '@/app/components/base/toast'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useModelList,
@ -114,15 +114,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
}, [scopedModelList, value?.provider, value?.model])
const hasDeprecated = useMemo(() => {
return !currentProvider || !currentModel
}, [currentModel, currentProvider])
const modelDisabled = useMemo(() => {
return currentModel?.status !== ModelStatusEnum.active
}, [currentModel?.status])
const disabled = useMemo(() => {
return !isAPIKeySet || hasDeprecated || modelDisabled
}, [hasDeprecated, isAPIKeySet, modelDisabled])
const hasDeprecated = !currentProvider || !currentModel
const disabled = !isAPIKeySet || hasDeprecated || currentModel?.status !== ModelStatusEnum.active
const handleChangeModel = async ({ provider, model }: DefaultModel) => {
const targetProvider = scopedModelList.find(modelItem => modelItem.provider === provider)
@ -187,99 +180,95 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={isInWorkflow ? 'left' : 'bottom-end'}
offset={4}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => {
if (readonly)
return
setOpen(v => !v)
}}
className="block"
<PopoverTrigger
render={(
<button type="button" className="block w-full border-none bg-transparent p-0 text-left [color:inherit] [font:inherit]">
{
renderTrigger
? renderTrigger({
open,
currentProvider,
currentModel,
providerName: value?.provider,
modelId: value?.model,
})
: (isAgentStrategy
? (
<AgentModelTrigger
disabled={disabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
scope={scope}
/>
)
: (
<Trigger
isInWorkflow={isInWorkflow}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
/>
)
)
}
</button>
)}
/>
<PopoverContent
placement={isInWorkflow ? 'left' : 'bottom-end'}
sideOffset={4}
className={portalToFollowElemContentClassName}
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
>
{
renderTrigger
? renderTrigger({
open,
disabled,
modelDisabled,
hasDeprecated,
currentProvider,
currentModel,
providerName: value?.provider,
modelId: value?.model,
})
: (isAgentStrategy
? (
<AgentModelTrigger
disabled={disabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
scope={scope}
/>
)
: (
<Trigger
disabled={disabled}
isInWorkflow={isInWorkflow}
modelDisabled={modelDisabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={value?.provider}
modelId={value?.model}
/>
)
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn('z-50', portalToFollowElemContentClassName)}>
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
<div className="relative">
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
modelList={scopedModelList}
scopeFeatures={scopeFeatures}
onSelect={handleChangeModel}
/>
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">
<div className="relative">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
<div className="my-3 h-px bg-divider-subtle" />
)}
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
<LLMParamsPanel
provider={value?.provider}
modelId={value?.model}
completionParams={value?.completion_params || {}}
onCompletionParamsChange={handleLLMParamsChange}
isAdvancedMode={isAdvancedMode}
/>
)}
{currentModel?.model_type === ModelTypeEnum.tts && (
<TTSParamsPanel
currentModel={currentModel}
language={value?.language}
voice={value?.voice}
onChange={handleTTSParamsChange}
/>
)}
<ModelSelector
defaultModel={(value?.provider || value?.model) ? { provider: value?.provider, model: value?.model } : undefined}
modelList={scopedModelList}
scopeFeatures={scopeFeatures}
onSelect={handleChangeModel}
/>
</div>
{(currentModel?.model_type === ModelTypeEnum.textGeneration || currentModel?.model_type === ModelTypeEnum.tts) && (
<div className="my-3 h-px bg-divider-subtle" />
)}
{currentModel?.model_type === ModelTypeEnum.textGeneration && (
<LLMParamsPanel
provider={value?.provider}
modelId={value?.model}
completionParams={value?.completion_params || {}}
onCompletionParamsChange={handleLLMParamsChange}
isAdvancedMode={isAdvancedMode}
/>
)}
{currentModel?.model_type === ModelTypeEnum.tts && (
<TTSParamsPanel
currentModel={currentModel}
language={value?.language}
voice={value?.voice}
onChange={handleTTSParamsChange}
/>
)}
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@ -1,16 +1,15 @@
'use client'
import type { FC } from 'react'
import { RiArrowRightUpLine, RiMoreFill } from '@remixicon/react'
import type { Placement } from '@/app/components/base/ui/placement'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
// import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import { PluginSource } from '../types'
@ -21,6 +20,10 @@ type Props = {
onCheckVersion: () => void
onRemove: () => void
detailUrl: string
placement?: Placement
sideOffset?: number
alignOffset?: number
popupClassName?: string
}
const OperationDropdown: FC<Props> = ({
@ -29,83 +32,52 @@ const OperationDropdown: FC<Props> = ({
onInfo,
onCheckVersion,
onRemove,
placement = 'bottom-end',
sideOffset = 4,
alignOffset = 0,
popupClassName,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const [open, setOpen] = React.useState(false)
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: -12,
crossAxis: 36,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<RiMoreFill className="h-4 w-4" />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{source === PluginSource.github && (
<div
onClick={() => {
onInfo()
handleTrigger()
}}
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
>
{t('detailPanel.operation.info', { ns: 'plugin' })}
</div>
)}
{source === PluginSource.github && (
<div
onClick={() => {
onCheckVersion()
handleTrigger()
}}
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover"
>
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
</div>
)}
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
<a href={detailUrl} target="_blank" className="system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover">
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="h-3.5 w-3.5 shrink-0 text-text-tertiary" />
</a>
)}
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
<div className="my-1 h-px bg-divider-subtle"></div>
)}
<div
onClick={() => {
onRemove()
handleTrigger()
}}
className="system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
>
{t('detailPanel.operation.remove', { ns: 'plugin' })}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m', open && 'bg-state-base-hover')}
>
<span className="i-ri-more-fill h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName={cn('w-auto min-w-[160px]', popupClassName)}
>
{source === PluginSource.github && (
<DropdownMenuItem onClick={onInfo}>
{t('detailPanel.operation.info', { ns: 'plugin' })}
</DropdownMenuItem>
)}
{source === PluginSource.github && (
<DropdownMenuItem onClick={onCheckVersion}>
{t('detailPanel.operation.checkUpdate', { ns: 'plugin' })}
</DropdownMenuItem>
)}
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
<DropdownMenuItem render={<a href={detailUrl} target="_blank" rel="noopener noreferrer" />}>
<span className="grow">{t('detailPanel.operation.viewDetail', { ns: 'plugin' })}</span>
<span className="i-ri-arrow-right-up-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
</DropdownMenuItem>
)}
{(source === PluginSource.marketplace || source === PluginSource.github) && enable_marketplace && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem destructive onClick={onRemove}>
{t('detailPanel.operation.remove', { ns: 'plugin' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default React.memo(OperationDropdown)

View File

@ -1,7 +1,7 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TaskStatus } from '@/app/components/plugins/types'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
// Import mocked modules
import { useMutationClearTaskPlugin, usePluginTaskList } from '@/service/use-plugins'
import PluginTaskList from '../components/plugin-task-list'
@ -30,6 +30,7 @@ vi.mock('@/context/i18n', () => ({
const createMockPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: `plugin-${Math.random().toString(36).substr(2, 9)}`,
plugin_id: 'test-plugin',
source: PluginSource.marketplace,
status: TaskStatus.running,
message: '',
icon: 'test-icon.png',
@ -438,7 +439,7 @@ describe('PluginTaskList Component', () => {
// Translation key is returned as text in tests, multiple matches expected (title + status)
expect(screen.getAllByText(/task\.installing/i).length).toBeGreaterThan(0)
// Verify section container is rendered
expect(document.querySelector('.max-h-\\[200px\\]')).toBeInTheDocument()
expect(document.querySelector('.max-h-\\[300px\\]')).toBeInTheDocument()
})
it('should render success plugins section when plugins exist', () => {
@ -467,7 +468,7 @@ describe('PluginTaskList Component', () => {
)
// All sections should be present
expect(document.querySelectorAll('.max-h-\\[200px\\]').length).toBe(3)
expect(document.querySelectorAll('.max-h-\\[300px\\]').length).toBe(3)
})
})
@ -523,8 +524,9 @@ describe('PluginTaskList Component', () => {
/>,
)
// The individual clear button has the text 'operation.clear'
fireEvent.click(screen.getByRole('button', { name: /operation\.clear/i }))
const closeButton = screen.getAllByRole('button')
.find(btn => btn.querySelector('.i-ri-close-line'))!
fireEvent.click(closeButton)
expect(handleClearSingle).toHaveBeenCalledWith('task-123', 'error-plugin-1')
})
@ -844,7 +846,7 @@ describe('PluginTasks Integration', () => {
fireEvent.click(document.getElementById('plugin-task-trigger')!)
// All sections should be visible
const sections = document.querySelectorAll('.max-h-\\[200px\\]')
const sections = document.querySelectorAll('.max-h-\\[300px\\]')
expect(sections.length).toBe(3)
})
})

View File

@ -0,0 +1,380 @@
import type { PluginInfoFromMarketPlace, PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { PluginCategoryEnum, PluginSource, TaskStatus } from '@/app/components/plugins/types'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import ErrorPluginItem from '../error-plugin-item'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ uniqueIdentifier, onClose, onSuccess }: { uniqueIdentifier: string, onClose: () => void, onSuccess: () => void }) => (
<div data-testid="install-modal" data-uid={uniqueIdentifier}>
<button onClick={onClose}>Close modal</button>
<button onClick={onSuccess}>Success</button>
</div>
),
}))
vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: vi.fn(),
}))
const mockFetch = vi.mocked(fetchPluginInfoFromMarketPlace)
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
function createMarketplaceResponse(identifier: string, version: string) {
return {
data: {
plugin: {
category: PluginCategoryEnum.tool,
latest_package_identifier: identifier,
latest_version: version,
} satisfies PluginInfoFromMarketPlace,
version: { version },
},
}
}
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: 'org/plugin:1.0.0',
plugin_id: 'org/plugin',
source: PluginSource.marketplace,
status: TaskStatus.failed,
message: '',
icon: 'icon.png',
labels: { en_US: 'Test Plugin' } as PluginStatus['labels'],
taskId: 'task-1',
...overrides,
})
describe('ErrorPluginItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render plugin name', () => {
render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
})
it('should render error status icon', () => {
const { container } = render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(container.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument()
})
it('should apply destructive text styling', () => {
render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
const errorText = screen.getByText(/plugin\.task\.errorMsg\.marketplace/i)
expect(errorText.closest('.text-text-destructive')).toBeInTheDocument()
})
})
describe('Source detection and error messages', () => {
it('should show marketplace error message for marketplace plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.marketplace/)).toBeInTheDocument()
})
it('should show github error message for github plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'https://github.com/user/repo' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.github/)).toBeInTheDocument()
})
it('should show unknown error message for unknown source plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'local-only-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
})
it('should show plugin.message when available instead of default error', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ message: 'Custom error occurred' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText('Custom error occurred')).toBeInTheDocument()
})
})
describe('Action buttons', () => {
it('should show "Install from Marketplace" button for marketplace plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromMarketplace/)).toBeInTheDocument()
})
it('should show "Install from GitHub" button for github plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'https://github.com/user/repo' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
})
it('should not show action button for unknown source plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'local-only-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.queryByText(/plugin\.task\.installFrom/)).not.toBeInTheDocument()
})
it('should use source instead of plugin_id heuristics when deciding button text', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
expect(screen.queryByText(/plugin\.task\.installFromMarketplace/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClear when clear button is clicked', () => {
const onClear = vi.fn()
render(
<ErrorPluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={onClear}
/>,
)
// The clear button (×) is from PluginItem
const buttons = screen.getAllByRole('button')
const clearButton = buttons.find(btn => !btn.textContent?.includes('plugin.task'))
fireEvent.click(clearButton!)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should fetch marketplace info and show install modal on button click', async () => {
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/my-plugin:2.0.0', '2.0.0'))
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
})
expect(mockFetch).toHaveBeenCalledWith({ org: 'org', name: 'my-plugin' })
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-uid', 'org/my-plugin:2.0.0')
})
it('should close install modal when onClose is called', async () => {
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/my-plugin:2.0.0', '2.0.0'))
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Close modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
it('should silently handle fetch failure', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/my-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
it('should not fetch when plugin_id has fewer than 2 parts', async () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'single-part' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
// Unknown source won't render the marketplace button, so nothing to click
expect(screen.queryByText(/plugin\.task\.installFromMarketplace/)).not.toBeInTheDocument()
expect(mockFetch).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should render github action when source is github even if plugin_id looks like a URL', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.github, plugin_id: 'http://github.com/user/repo' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
})
it('should close install modal and clear the error item when onSuccess is called', async () => {
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/p:1.0.0', '1.0.0'))
const onClear = vi.fn()
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.marketplace, plugin_id: 'org/p' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={onClear}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
await waitFor(() => {
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Success'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should show unknown action state for local source even if id contains github keyword', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.local, plugin_id: 'my-github-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.queryByText(/plugin\.task\.installFromGithub/)).not.toBeInTheDocument()
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
})
it('should show unknown error message for debugging source plugins', () => {
render(
<ErrorPluginItem
plugin={createPlugin({ source: PluginSource.debugging, plugin_id: 'remote-plugin' })}
getIconUrl={mockGetIconUrl}
language="en_US"
onClear={vi.fn()}
/>,
)
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
expect(screen.queryByText(/plugin\.task\.installFrom/)).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,221 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginItem from '../plugin-item'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
const mockGetIconUrl = vi.fn((icon: string) => `https://example.com/icons/${icon}`)
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: 'org/plugin:1.0.0',
plugin_id: 'org/plugin',
source: PluginSource.marketplace,
status: TaskStatus.running,
message: '',
icon: 'icon.png',
labels: {
en_US: 'Test Plugin',
zh_Hans: '测试插件',
} as PluginStatus['labels'],
taskId: 'task-1',
...overrides,
})
describe('PluginItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render plugin name based on language', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="Installing..."
/>,
)
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
})
it('should render status text', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="Installing... please wait"
/>,
)
expect(screen.getByText('Installing... please wait')).toBeInTheDocument()
})
it('should render status icon', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="status"
/>,
)
expect(screen.getByTestId('status-icon')).toBeInTheDocument()
})
it('should anchor the status icon to the card icon wrapper', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="status"
/>,
)
const cardIcon = screen.getByTestId('card-icon')
const iconWrapper = cardIcon.parentElement
expect(iconWrapper).toHaveClass('relative', 'self-start')
expect(screen.getByTestId('status-icon').parentElement).toHaveClass('absolute', '-bottom-0.5', '-right-0.5')
})
it('should pass icon url to CardIcon', () => {
render(
<PluginItem
plugin={createPlugin({ icon: 'my-icon.svg' })}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
/>,
)
expect(mockGetIconUrl).toHaveBeenCalledWith('my-icon.svg')
const cardIcon = screen.getByTestId('card-icon')
expect(cardIcon).toHaveAttribute('data-src', 'https://example.com/icons/my-icon.svg')
expect(cardIcon).toHaveAttribute('data-size', 'small')
})
})
describe('Props', () => {
it('should apply custom statusClassName', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="done"
statusClassName="text-text-success"
/>,
)
expect(screen.getByText('done').className).toContain('text-text-success')
})
it('should apply default statusClassName when not provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="done"
/>,
)
expect(screen.getByText('done').className).toContain('text-text-tertiary')
})
it('should render action when provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
action={<button>Install</button>}
/>,
)
expect(screen.getByRole('button', { name: /install/i })).toBeInTheDocument()
})
it('should not render action when not provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should render zh-Hans label when language is zh_Hans', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="zh_Hans"
statusIcon={<span />}
statusText="status"
/>,
)
expect(screen.getByText('测试插件')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should render clear button when onClear is provided', () => {
const handleClear = vi.fn()
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
onClear={handleClear}
/>,
)
const clearButton = screen.getByRole('button')
fireEvent.click(clearButton)
expect(handleClear).toHaveBeenCalledTimes(1)
})
it('should not render clear button when onClear is not provided', () => {
render(
<PluginItem
plugin={createPlugin()}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span />}
statusText="status"
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,164 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginSection from '../plugin-section'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
const createPlugin = (id: string, name: string, message = ''): PluginStatus => ({
plugin_unique_identifier: id,
plugin_id: `org/${name.toLowerCase()}`,
source: PluginSource.marketplace,
status: TaskStatus.running,
message,
icon: `${name.toLowerCase()}.png`,
labels: { en_US: name, zh_Hans: name } as PluginStatus['labels'],
taskId: 'task-1',
})
const defaultProps = {
title: 'Installing plugins',
count: 2,
plugins: [createPlugin('p1', 'PluginA'), createPlugin('p2', 'PluginB')],
getIconUrl: mockGetIconUrl,
language: 'en_US' as const,
statusIcon: <span data-testid="status-icon" />,
defaultStatusText: 'Default status',
}
describe('PluginSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title and count', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.getByText(/installing plugins/i)).toBeInTheDocument()
expect(screen.getByText(/installing plugins/i).textContent).toContain('2')
})
it('should render all plugin items', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.getByText('PluginA')).toBeInTheDocument()
expect(screen.getByText('PluginB')).toBeInTheDocument()
})
it('should render status icons for each plugin', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.getAllByTestId('status-icon')).toHaveLength(2)
})
})
describe('Props', () => {
it('should return null when plugins array is empty', () => {
const { container } = render(
<PluginSection {...defaultProps} plugins={[]} />,
)
expect(container.innerHTML).toBe('')
})
it('should use plugin.message as statusText when available', () => {
const plugins = [createPlugin('p1', 'PluginA', 'Custom message')]
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
expect(screen.getByText('Custom message')).toBeInTheDocument()
})
it('should use defaultStatusText when plugin has no message', () => {
const plugins = [createPlugin('p1', 'PluginA', '')]
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
expect(screen.getByText('Default status')).toBeInTheDocument()
})
it('should apply statusClassName to items', () => {
const plugins = [createPlugin('p1', 'PluginA')]
render(
<PluginSection
{...defaultProps}
plugins={plugins}
count={1}
statusClassName="text-text-success"
/>,
)
expect(screen.getByText('Default status').className).toContain('text-text-success')
})
it('should render headerAction when provided', () => {
render(
<PluginSection
{...defaultProps}
headerAction={<button>Clear all</button>}
/>,
)
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument()
})
it('should not render headerAction when not provided', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should render item actions via renderItemAction', () => {
render(
<PluginSection
{...defaultProps}
renderItemAction={plugin => (
<button>{`Action ${plugin.labels.en_US}`}</button>
)}
/>,
)
expect(screen.getByRole('button', { name: /action plugina/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /action pluginb/i })).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClearSingle with taskId and plugin identifier', () => {
const onClearSingle = vi.fn()
render(
<PluginSection
{...defaultProps}
onClearSingle={onClearSingle}
/>,
)
// Clear buttons are rendered when onClearSingle is provided
const clearButtons = screen.getAllByRole('button')
fireEvent.click(clearButtons[0])
expect(onClearSingle).toHaveBeenCalledWith('task-1', 'p1')
})
it('should not render clear buttons when onClearSingle is not provided', () => {
render(<PluginSection {...defaultProps} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle single plugin', () => {
const plugins = [createPlugin('p1', 'Solo')]
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
expect(screen.getByText('Solo')).toBeInTheDocument()
expect(screen.getByText(/solo/i).closest('.max-h-\\[300px\\]')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,251 @@
import type { PluginStatus } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginTaskList from '../plugin-task-list'
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: () => <div data-testid="install-modal" />,
}))
vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
const createPlugin = (id: string, name: string, overrides: Partial<PluginStatus> = {}): PluginStatus => ({
plugin_unique_identifier: id,
plugin_id: `org/${name.toLowerCase()}`,
source: PluginSource.marketplace,
status: TaskStatus.running,
message: '',
icon: `${name.toLowerCase()}.png`,
labels: { en_US: name } as PluginStatus['labels'],
taskId: 'task-1',
...overrides,
})
const runningPlugins = [
createPlugin('r1', 'OpenAI', { status: TaskStatus.running }),
createPlugin('r2', 'Anthropic', { status: TaskStatus.running }),
]
const successPlugins = [
createPlugin('s1', 'Google', { status: TaskStatus.success }),
]
const errorPlugins = [
createPlugin('e1', 'DALLE', { status: TaskStatus.failed, plugin_id: 'org/dalle' }),
]
describe('PluginTaskList', () => {
const defaultProps = {
runningPlugins: [] as PluginStatus[],
successPlugins: [] as PluginStatus[],
errorPlugins: [] as PluginStatus[],
getIconUrl: mockGetIconUrl,
onClearAll: vi.fn(),
onClearErrors: vi.fn(),
onClearSingle: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty container when no plugins', () => {
const { container } = render(<PluginTaskList {...defaultProps} />)
const wrapper = container.firstElementChild!
expect(wrapper).toBeInTheDocument()
expect(wrapper.children).toHaveLength(0)
})
it('should render running section when running plugins exist', () => {
const { container } = render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// Header contains the title text
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
expect(headers).toHaveLength(1)
expect(headers[0].textContent).toContain('plugin.task.installing')
expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('Anthropic')).toBeInTheDocument()
})
it('should render success section when success plugins exist', () => {
const { container } = render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
expect(headers).toHaveLength(1)
expect(headers[0].textContent).toContain('plugin.task.installed')
expect(screen.getByText('Google')).toBeInTheDocument()
})
it('should render error section when error plugins exist', () => {
const { container } = render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
expect(headers).toHaveLength(1)
expect(headers[0].textContent).toContain('plugin.task.installedError')
expect(screen.getByText('DALLE')).toBeInTheDocument()
})
it('should render all three sections simultaneously', () => {
render(
<PluginTaskList
{...defaultProps}
runningPlugins={runningPlugins}
successPlugins={successPlugins}
errorPlugins={errorPlugins}
/>,
)
expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('Google')).toBeInTheDocument()
expect(screen.getByText('DALLE')).toBeInTheDocument()
})
})
describe('Clear actions', () => {
it('should show Clear all button in success section', () => {
render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
const clearButtons = screen.getAllByText(/plugin\.task\.clearAll/)
expect(clearButtons).toHaveLength(1)
})
it('should call onClearAll when success section Clear all is clicked', () => {
const onClearAll = vi.fn()
render(
<PluginTaskList
{...defaultProps}
successPlugins={successPlugins}
onClearAll={onClearAll}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.clearAll/))
expect(onClearAll).toHaveBeenCalledTimes(1)
})
it('should show Clear all button in error section', () => {
render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
const clearButtons = screen.getAllByText(/plugin\.task\.clearAll/)
expect(clearButtons).toHaveLength(1)
})
it('should call onClearErrors when error section Clear all is clicked', () => {
const onClearErrors = vi.fn()
render(
<PluginTaskList
{...defaultProps}
errorPlugins={errorPlugins}
onClearErrors={onClearErrors}
/>,
)
fireEvent.click(screen.getByText(/plugin\.task\.clearAll/))
expect(onClearErrors).toHaveBeenCalledTimes(1)
})
it('should call onClearSingle from success section clear button', () => {
const onClearSingle = vi.fn()
render(
<PluginTaskList
{...defaultProps}
successPlugins={successPlugins}
onClearSingle={onClearSingle}
/>,
)
// The × close button from PluginItem (rendered inside PluginSection)
const closeButtons = screen.getAllByRole('button')
const clearItemBtn = closeButtons.find(btn => !btn.textContent?.includes('plugin.task'))
if (clearItemBtn)
fireEvent.click(clearItemBtn)
expect(onClearSingle).toHaveBeenCalledWith('task-1', 's1')
})
})
describe('Running section', () => {
it('should not render clear buttons for running plugins', () => {
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// Running section has no headerAction and no onClearSingle
expect(screen.queryByText(/plugin\.task\.clearAll/)).not.toBeInTheDocument()
})
it('should show installing hint as status text', () => {
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
// defaultStatusText is t('task.installingHint', { ns: 'plugin' })
const hintTexts = screen.getAllByText(/plugin\.task\.installingHint/)
expect(hintTexts.length).toBeGreaterThan(0)
})
})
describe('Error section clear single', () => {
it('should call onClearSingle from error item clear button', () => {
const onClearSingle = vi.fn()
render(
<PluginTaskList
{...defaultProps}
errorPlugins={errorPlugins}
onClearSingle={onClearSingle}
/>,
)
// Find the × close button inside error items (not the "Clear all" button)
const allButtons = screen.getAllByRole('button')
const clearItemBtn = allButtons.find(btn =>
!btn.textContent?.includes('plugin.task')
&& !btn.textContent?.includes('installFrom'),
)
if (clearItemBtn)
fireEvent.click(clearItemBtn)
expect(onClearSingle).toHaveBeenCalledWith('task-1', 'e1')
})
})
describe('Edge Cases', () => {
it('should not render sections for empty plugin arrays', () => {
const { container } = render(
<PluginTaskList
{...defaultProps}
runningPlugins={[]}
successPlugins={[]}
errorPlugins={[]}
/>,
)
expect(container.querySelector('.w-\\[360px\\]')!.children).toHaveLength(0)
})
it('should render error section with multiple error plugins', () => {
const multipleErrors = [
createPlugin('e1', 'PluginA', { status: TaskStatus.failed, plugin_id: 'org/a' }),
createPlugin('e2', 'PluginB', { status: TaskStatus.failed, plugin_id: 'https://github.com/b' }),
createPlugin('e3', 'PluginC', { status: TaskStatus.failed, plugin_id: 'local-only' }),
]
render(<PluginTaskList {...defaultProps} errorPlugins={multipleErrors} />)
expect(screen.getByText('PluginA')).toBeInTheDocument()
expect(screen.getByText('PluginB')).toBeInTheDocument()
expect(screen.getByText('PluginC')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,237 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TaskStatusIndicator from '../task-status-indicator'
vi.mock('@/app/components/base/progress-bar/progress-circle', () => ({
default: ({ percentage }: { percentage: number }) => (
<div data-testid="progress-circle" data-percentage={percentage} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" data-tip={popupContent}>{children}</div>
),
}))
vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({
default: () => <span data-testid="downloading-icon" />,
}))
const defaultProps = {
tip: 'Installing plugins',
isInstalling: false,
isInstallingWithSuccess: false,
isInstallingWithError: false,
isSuccess: false,
isFailed: false,
successPluginsLength: 0,
runningPluginsLength: 0,
totalPluginsLength: 0,
onClick: vi.fn(),
}
describe('TaskStatusIndicator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should pass tip to tooltip', () => {
render(<TaskStatusIndicator {...defaultProps} tip="My tip" />)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-tip', 'My tip')
})
it('should render install icon by default', () => {
const { container } = render(<TaskStatusIndicator {...defaultProps} />)
// RiInstallLine renders as svg
expect(container.querySelector('svg')).toBeInTheDocument()
expect(screen.queryByTestId('downloading-icon')).not.toBeInTheDocument()
})
})
describe('Installing state', () => {
it('should show downloading icon when isInstalling', () => {
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
expect(screen.getByTestId('downloading-icon')).toBeInTheDocument()
})
it('should show downloading icon when isInstallingWithError', () => {
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
expect(screen.getByTestId('downloading-icon')).toBeInTheDocument()
})
it('should show progress circle when isInstalling', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
successPluginsLength={2}
totalPluginsLength={5}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '40')
})
it('should show progress circle when isInstallingWithSuccess', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithSuccess
successPluginsLength={3}
totalPluginsLength={4}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '75')
})
it('should show error progress circle when isInstallingWithError', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithError
runningPluginsLength={1}
totalPluginsLength={3}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toBeInTheDocument()
})
it('should handle zero totalPluginsLength without division error', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
totalPluginsLength={0}
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '0')
})
})
describe('Success state', () => {
it('should show success icon when isSuccess', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
isSuccess
successPluginsLength={3}
totalPluginsLength={3}
/>,
)
// RiCheckboxCircleFill is rendered as svg with text-text-success
const successIcon = container.querySelector('.text-text-success')
expect(successIcon).toBeInTheDocument()
})
it('should show success icon when successPlugins > 0 and no running plugins', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
successPluginsLength={2}
runningPluginsLength={0}
totalPluginsLength={2}
/>,
)
const successIcon = container.querySelector('.text-text-success')
expect(successIcon).toBeInTheDocument()
})
it('should not show success icon during installing states', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
isInstalling
successPluginsLength={1}
runningPluginsLength={1}
totalPluginsLength={2}
/>,
)
// Progress circle shown instead of success icon
expect(screen.getByTestId('progress-circle')).toBeInTheDocument()
expect(container.querySelector('.text-text-success')).not.toBeInTheDocument()
})
})
describe('Failed state', () => {
it('should show error icon when isFailed', () => {
const { container } = render(
<TaskStatusIndicator
{...defaultProps}
isFailed
totalPluginsLength={2}
/>,
)
const errorIcon = container.querySelector('.text-text-destructive')
expect(errorIcon).toBeInTheDocument()
})
it('should apply destructive styling when isFailed', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isFailed
totalPluginsLength={1}
/>,
)
const button = document.getElementById('plugin-task-trigger')!
expect(button.className).toContain('bg-state-destructive-hover')
})
it('should apply destructive styling when isInstallingWithError', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isInstallingWithError
totalPluginsLength={2}
/>,
)
const button = document.getElementById('plugin-task-trigger')!
expect(button.className).toContain('bg-state-destructive-hover')
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const onClick = vi.fn()
render(<TaskStatusIndicator {...defaultProps} onClick={onClick} />)
const button = document.getElementById('plugin-task-trigger')!
fireEvent.click(button)
expect(onClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should apply cursor-pointer for interactive states', () => {
render(
<TaskStatusIndicator
{...defaultProps}
isSuccess
successPluginsLength={1}
totalPluginsLength={1}
/>,
)
const button = document.getElementById('plugin-task-trigger')!
expect(button.className).toContain('cursor-pointer')
})
it('should not show any badge indicators when all flags are false', () => {
render(<TaskStatusIndicator {...defaultProps} />)
expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument()
const button = document.getElementById('plugin-task-trigger')!
// No success or error icons in the badge area
expect(button.querySelector('.text-text-success')).not.toBeInTheDocument()
expect(button.querySelector('.text-text-destructive')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,134 @@
import type { FC } from 'react'
import type { Plugin, PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { PluginSource } from '@/app/components/plugins/types'
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
import PluginItem from './plugin-item'
type ErrorPluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
onClear: () => void
}
const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, language, onClear }) => {
const { t } = useTranslation()
const source = plugin.source
const [showInstallModal, setShowInstallModal] = useState(false)
const [installPayload, setInstallPayload] = useState<{ uniqueIdentifier: string, manifest: Plugin } | null>(null)
const [isFetching, setIsFetching] = useState(false)
const handleInstallFromMarketplace = useCallback(async () => {
const parts = plugin.plugin_id.split('/')
if (parts.length < 2)
return
const [org, name] = parts
setIsFetching(true)
try {
const response = await fetchPluginInfoFromMarketPlace({ org, name })
const info = response.data.plugin
const manifest: Plugin = {
plugin_id: plugin.plugin_id,
type: info.category as Plugin['type'],
category: info.category,
name,
org,
version: info.latest_version,
latest_version: info.latest_version,
latest_package_identifier: info.latest_package_identifier,
label: plugin.labels,
brief: {},
description: {},
icon: plugin.icon,
verified: true,
introduction: '',
repository: '',
install_count: 0,
endpoint: { settings: [] },
tags: [],
badges: [],
verification: { authorized_category: 'langgenius' },
from: 'marketplace',
}
setInstallPayload({ uniqueIdentifier: info.latest_package_identifier, manifest })
setShowInstallModal(true)
}
catch {
// silently fail
}
finally {
setIsFetching(false)
}
}, [plugin.plugin_id, plugin.labels, plugin.icon])
const errorMsgKey: 'task.errorMsg.marketplace' | 'task.errorMsg.github' | 'task.errorMsg.unknown' = source === PluginSource.marketplace
? 'task.errorMsg.marketplace'
: source === PluginSource.github
? 'task.errorMsg.github'
: 'task.errorMsg.unknown'
const errorMsg = t(errorMsgKey, { ns: 'plugin' })
const renderAction = () => {
if (source === PluginSource.marketplace) {
return (
<div className="pt-1">
<Button variant="secondary" size="small" loading={isFetching} onClick={handleInstallFromMarketplace}>
{t('task.installFromMarketplace', { ns: 'plugin' })}
</Button>
</div>
)
}
if (source === PluginSource.github) {
return (
<div className="pt-1">
<Button variant="secondary" size="small">
{t('task.installFromGithub', { ns: 'plugin' })}
</Button>
</div>
)
}
return undefined
}
return (
<>
<PluginItem
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
statusIcon={(
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-components-panel-bg bg-components-panel-bg">
<span className="i-ri-error-warning-fill h-4 w-4 text-text-destructive" />
</span>
)}
statusText={(
<span className="whitespace-pre-line">
{plugin.message || errorMsg}
</span>
)}
statusClassName="text-text-destructive"
action={renderAction()}
onClear={onClear}
/>
{showInstallModal && installPayload && (
<InstallFromMarketplace
uniqueIdentifier={installPayload.uniqueIdentifier}
manifest={installPayload.manifest}
onClose={() => setShowInstallModal(false)}
onSuccess={() => {
setShowInstallModal(false)
onClear()
}}
/>
)}
</>
)
}
export default ErrorPluginItem

View File

@ -0,0 +1,60 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
export type PluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
statusText: ReactNode
statusClassName?: string
action?: ReactNode
onClear?: () => void
}
const PluginItem: FC<PluginItemProps> = ({
plugin,
getIconUrl,
language,
statusIcon,
statusText,
statusClassName,
action,
onClear,
}) => {
return (
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative shrink-0 self-start">
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
<div className="absolute -bottom-0.5 -right-0.5 z-10">
{statusIcon}
</div>
</div>
<div className="flex min-w-0 grow flex-col gap-0.5 px-1">
<div className="truncate text-text-secondary system-sm-medium">
{plugin.labels[language]}
</div>
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
{statusText}
</div>
{action}
</div>
{onClear && (
<button
type="button"
className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-state-base-hover-alt group-hover/item:flex"
onClick={onClear}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
)}
</div>
)
}
export default PluginItem

View File

@ -0,0 +1,67 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import PluginItem from './plugin-item'
export type PluginSectionProps = {
title: string
count: number
plugins: PluginStatus[]
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
defaultStatusText: ReactNode
statusClassName?: string
headerAction?: ReactNode
renderItemAction?: (plugin: PluginStatus) => ReactNode
onClearSingle?: (taskId: string, pluginId: string) => void
}
const PluginSection: FC<PluginSectionProps> = ({
title,
count,
plugins,
getIconUrl,
language,
statusIcon,
defaultStatusText,
statusClassName,
headerAction,
renderItemAction,
onClearSingle,
}) => {
if (plugins.length === 0)
return null
return (
<>
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
{title}
{' '}
(
{count}
)
{headerAction}
</div>
<div className="max-h-[300px] overflow-y-auto">
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
statusIcon={statusIcon}
statusText={plugin.message || defaultStatusText}
statusClassName={statusClassName}
action={renderItemAction?.(plugin)}
onClear={onClearSingle
? () => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)
: undefined}
/>
))}
</div>
</>
)
}
export default PluginSection

View File

@ -1,39 +1,10 @@
import type { FC, ReactNode } from 'react'
import type { FC } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoaderLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
import { useGetLanguage } from '@/context/i18n'
// Types
type PluginItemProps = {
plugin: PluginStatus
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
statusText: string
statusClassName?: string
action?: ReactNode
}
type PluginSectionProps = {
title: string
count: number
plugins: PluginStatus[]
getIconUrl: (icon: string) => string
language: Locale
statusIcon: ReactNode
defaultStatusText: string
statusClassName?: string
headerAction?: ReactNode
renderItemAction?: (plugin: PluginStatus) => ReactNode
}
import ErrorPluginItem from './error-plugin-item'
import PluginSection from './plugin-section'
type PluginTaskListProps = {
runningPlugins: PluginStatus[]
@ -45,83 +16,6 @@ type PluginTaskListProps = {
onClearSingle: (taskId: string, pluginId: string) => void
}
// Plugin Item Component
const PluginItem: FC<PluginItemProps> = ({
plugin,
getIconUrl,
language,
statusIcon,
statusText,
statusClassName,
action,
}) => {
return (
<div className="flex items-center rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
{statusIcon}
<CardIcon
size="tiny"
src={getIconUrl(plugin.icon)}
/>
</div>
<div className="grow">
<div className="system-md-regular truncate text-text-secondary">
{plugin.labels[language]}
</div>
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
{statusText}
</div>
</div>
{action}
</div>
)
}
// Plugin Section Component
const PluginSection: FC<PluginSectionProps> = ({
title,
count,
plugins,
getIconUrl,
language,
statusIcon,
defaultStatusText,
statusClassName,
headerAction,
renderItemAction,
}) => {
if (plugins.length === 0)
return null
return (
<>
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
{title}
{' '}
(
{count}
)
{headerAction}
</div>
<div className="max-h-[200px] overflow-y-auto">
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
statusIcon={statusIcon}
statusText={plugin.message || defaultStatusText}
statusClassName={statusClassName}
action={renderItemAction?.(plugin)}
/>
))}
</div>
</>
)
}
// Main Plugin Task List Component
const PluginTaskList: FC<PluginTaskListProps> = ({
runningPlugins,
successPlugins,
@ -145,9 +39,9 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
<span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
}
defaultStatusText={t('task.installing', { ns: 'plugin' })}
defaultStatusText={t('task.installingHint', { ns: 'plugin' })}
/>
)}
@ -160,7 +54,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
}
defaultStatusText={t('task.installed', { ns: 'plugin' })}
statusClassName="text-text-success"
@ -174,23 +68,15 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
onClearSingle={onClearSingle}
/>
)}
{/* Error Plugins Section */}
{errorPlugins.length > 0 && (
<PluginSection
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
count={errorPlugins.length}
plugins={errorPlugins}
getIconUrl={getIconUrl}
language={language}
statusIcon={
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
}
defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
statusClassName="text-text-destructive break-all"
headerAction={(
<>
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
{t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
<Button
className="shrink-0"
size="small"
@ -199,18 +85,19 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
>
{t('task.clearAll', { ns: 'plugin' })}
</Button>
)}
renderItemAction={plugin => (
<Button
className="shrink-0"
size="small"
variant="ghost"
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
>
{t('operation.clear', { ns: 'common' })}
</Button>
)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{errorPlugins.map(plugin => (
<ErrorPluginItem
key={plugin.plugin_unique_identifier}
plugin={plugin}
getIconUrl={getIconUrl}
language={language}
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
</div>
</>
)}
</div>
)

View File

@ -9,8 +9,8 @@ import Loading from '@/app/components/base/loading'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { useGetLanguage } from '@/context/i18n'
import { renderI18nObject } from '@/i18n-config'
import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { PluginSource } from '../types'
import { useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { usePluginsWithLatestVersion } from '../hooks'
import { usePluginPageContext } from './context'
import Empty from './empty'
import FilterManagement from './filter-management'
@ -47,11 +47,7 @@ const PluginsPanel = () => {
const filters = usePluginPageContext(v => v.filters) as FilterState
const setFilters = usePluginPageContext(v => v.setFilters)
const { data: pluginList, isLoading: isPluginListLoading, isFetching, isLastPage, loadNextPage } = useInstalledPluginList()
const { data: installedLatestVersion } = useInstalledLatestVersion(
pluginList?.plugins
.filter(plugin => plugin.source === PluginSource.marketplace)
.map(plugin => plugin.plugin_id) ?? [],
)
const pluginListWithLatestVersion = usePluginsWithLatestVersion(pluginList?.plugins)
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const currentPluginID = usePluginPageContext(v => v.currentPluginID)
const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID)
@ -60,17 +56,6 @@ const PluginsPanel = () => {
setFilters(filters)
}, { wait: 500 })
const pluginListWithLatestVersion = useMemo(() => {
return pluginList?.plugins.map(plugin => ({
...plugin,
latest_version: installedLatestVersion?.versions[plugin.plugin_id]?.version ?? '',
latest_unique_identifier: installedLatestVersion?.versions[plugin.plugin_id]?.unique_identifier ?? '',
status: installedLatestVersion?.versions[plugin.plugin_id]?.status ?? 'active',
deprecated_reason: installedLatestVersion?.versions[plugin.plugin_id]?.deprecated_reason ?? '',
alternative_plugin_id: installedLatestVersion?.versions[plugin.plugin_id]?.alternative_plugin_id ?? '',
})) || []
}, [pluginList, installedLatestVersion])
const filteredList = useMemo(() => {
const { categories, searchQuery, tags } = filters
const filteredList = pluginListWithLatestVersion.filter((plugin) => {

View File

@ -432,6 +432,7 @@ export enum TaskStatus {
export type PluginStatus = {
plugin_unique_identifier: string
plugin_id: string
source: PluginSource
status: TaskStatus
message: string
icon: string
@ -508,6 +509,8 @@ export type GitHubItemAndMarketPlaceDependency = {
type: 'github' | 'marketplace' | 'package'
value: {
repo?: string
organization?: string // from bundle marketplace dependency
plugin?: string // from bundle marketplace dependency
version?: string // from app DSL
package?: string // from app DSL
release?: string // from local package. same to the version

View File

@ -104,36 +104,6 @@ vi.mock('../../install-plugin/install-from-github', () => ({
),
}))
// Mock Portal components for PluginVersionPicker
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => {
mockPortalOpen = open
return <div data-testid="portal-elem" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick, className }: {
children: React.ReactNode
onClick: () => void
className?: string
}) => (
<div data-testid="portal-trigger" onClick={onClick} className={className}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: {
children: React.ReactNode
className?: string
}) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content" className={className}>{children}</div>
},
}))
// Mock semver
vi.mock('semver', () => ({
lt: (v1: string, v2: string) => {
@ -247,7 +217,6 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
describe('update-plugin', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockCheck.mockResolvedValue({ status: TaskStatus.success })
})
@ -732,8 +701,8 @@ describe('update-plugin', () => {
})
})
it('should show error toast when task status is failed', async () => {
// Arrange - covers lines 99-100
it('should reset loading state when task status check fails', async () => {
// Arrange
const mockToastNotify = vi.fn()
vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify
@ -770,6 +739,53 @@ describe('update-plugin', () => {
})
// onSave should NOT be called when task fails
expect(onSave).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should stop loading when upgrade API returns failed task directly', async () => {
// Arrange
const mockToastNotify = vi.fn()
vi.mocked(await import('../../../base/toast')).default.notify = mockToastNotify
mockUpdateFromMarketPlace.mockResolvedValue({
task: {
status: TaskStatus.failed,
plugins: [{
plugin_unique_identifier: 'test-target-id',
status: TaskStatus.failed,
message: 'failed to init environment',
}],
},
})
const onSave = vi.fn()
const payload = createMockMarketPlacePayload()
// Act
renderWithQueryClient(
<UpdateFromMarketplace
payload={payload}
onSave={onSave}
onCancel={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' }))
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'failed to init environment',
})
})
expect(mockCheck).not.toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'plugin.upgrade.upgrade' })).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
})
@ -946,7 +962,7 @@ describe('update-plugin', () => {
onShowChange: vi.fn(),
pluginID: 'test-plugin-id',
currentVersion: '1.0.0',
trigger: <button>Select Version</button>,
trigger: <span>Select Version</span>,
onSelect: vi.fn(),
}
@ -964,7 +980,7 @@ describe('update-plugin', () => {
render(<PluginVersionPicker {...defaultProps} isShow={false} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.detailPanel.switchVersion')).not.toBeInTheDocument()
})
it('should render version list when isShow is true', () => {
@ -972,7 +988,6 @@ describe('update-plugin', () => {
render(<PluginVersionPicker {...defaultProps} isShow={true} />)
// Assert
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
})
@ -1002,7 +1017,7 @@ describe('update-plugin', () => {
// Act
render(<PluginVersionPicker {...defaultProps} onShowChange={onShowChange} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('Select Version'))
// Assert
expect(onShowChange).toHaveBeenCalledWith(true)
@ -1014,7 +1029,7 @@ describe('update-plugin', () => {
// Act
render(<PluginVersionPicker {...defaultProps} disabled={true} onShowChange={onShowChange} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('Select Version'))
// Assert
expect(onShowChange).not.toHaveBeenCalled()
@ -1116,7 +1131,7 @@ describe('update-plugin', () => {
)
// Assert
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
})
it('should support custom offset', () => {
@ -1125,12 +1140,13 @@ describe('update-plugin', () => {
<PluginVersionPicker
{...defaultProps}
isShow={true}
offset={{ mainAxis: 10, crossAxis: 20 }}
sideOffset={10}
alignOffset={20}
/>,
)
// Assert
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument()
})
})
@ -1190,7 +1206,7 @@ describe('update-plugin', () => {
onShowChange: vi.fn(),
pluginID: 'test',
currentVersion: '1.0.0',
trigger: <button>Select</button>,
trigger: <span>Select</span>,
onSelect: vi.fn(),
}}
/>,

View File

@ -18,8 +18,8 @@ const DowngradeWarningModal = ({
return (
<>
<div className="flex flex-col items-start gap-2 self-stretch">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</div>
<div className="system-md-regular text-text-secondary">
<div className="text-text-primary title-2xl-semi-bold">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</div>
<div className="text-text-secondary system-md-regular">
{t(`${i18nPrefix}.description`, { ns: 'plugin' })}
</div>
</div>

View File

@ -6,7 +6,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@/app/components/base/ui/dialog'
import Card from '@/app/components/plugins/card'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import { pluginManifestToCardPluginProps } from '@/app/components/plugins/install-plugin/utils'
@ -28,6 +33,16 @@ type Props = {
isShowDowngradeWarningModal?: boolean
}
type FailedUpgradeResponse = {
task?: {
status?: TaskStatus
plugins?: Array<{
plugin_unique_identifier: string
message: string
}>
}
}
enum UploadStep {
notStarted = 'notStarted',
upgrading = 'upgrading',
@ -78,13 +93,26 @@ const UpdatePluginModal: FC<Props> = ({
if (uploadStep === UploadStep.notStarted) {
setUploadStep(UploadStep.upgrading)
try {
const response = await updateFromMarketPlace({
original_plugin_unique_identifier: originalPackageInfo.id,
new_plugin_unique_identifier: targetPackageInfo.id,
}) as Awaited<ReturnType<typeof updateFromMarketPlace>> & FailedUpgradeResponse
if (response.task?.status === TaskStatus.failed) {
const failedPlugin = response.task.plugins?.find(plugin => plugin.plugin_unique_identifier === targetPackageInfo.id)
?? response.task.plugins?.[0]
Toast.notify({
type: 'error',
message: failedPlugin?.message || t('error', { ns: 'common' }),
})
setUploadStep(UploadStep.notStarted)
return
}
const {
all_installed: isInstalled,
task_id: taskId,
} = await updateFromMarketPlace({
original_plugin_unique_identifier: originalPackageInfo.id,
new_plugin_unique_identifier: targetPackageInfo.id,
})
} = response
if (isInstalled) {
onSave()
@ -97,6 +125,7 @@ const UpdatePluginModal: FC<Props> = ({
})
if (status === TaskStatus.failed) {
Toast.notify({ type: 'error', message: error! })
setUploadStep(UploadStep.notStarted)
return
}
onSave()
@ -109,7 +138,7 @@ const UpdatePluginModal: FC<Props> = ({
}
if (uploadStep === UploadStep.installed)
onSave()
}, [onSave, uploadStep, check, originalPackageInfo.id, handleRefetch, targetPackageInfo.id])
}, [onSave, uploadStep, check, originalPackageInfo.id, handleRefetch, t, targetPackageInfo.id])
const { mutateAsync } = useRemoveAutoUpgrade()
const invalidateReferenceSettings = useInvalidateReferenceSettings()
@ -125,63 +154,65 @@ const UpdatePluginModal: FC<Props> = ({
const doShowDowngradeWarningModal = isShowDowngradeWarningModal && uploadStep === UploadStep.notStarted
return (
<Modal
isShow={true}
onClose={onCancel}
className={cn('min-w-[560px]', doShowDowngradeWarningModal && 'min-w-[640px]')}
closable
title={!doShowDowngradeWarningModal && t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`, { ns: 'plugin' })}
>
{doShowDowngradeWarningModal && (
<DowngradeWarningModal
onCancel={onCancel}
onJustDowngrade={handleConfirm}
onExcludeAndDowngrade={handleExcludeAndDownload}
/>
)}
{!doShowDowngradeWarningModal && (
<>
<div className="system-md-regular mb-2 mt-3 text-text-secondary">
{t(`${i18nPrefix}.description`, { ns: 'plugin' })}
</div>
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
<Card
installed={uploadStep === UploadStep.installed}
payload={pluginManifestToCardPluginProps({
...originalPackageInfo.payload,
icon: icon!,
})}
className="w-full"
titleLeft={(
<>
<Badge className="mx-1" size="s" state={BadgeState.Warning}>
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
</Badge>
</>
<Dialog open onOpenChange={() => onCancel()}>
<DialogContent
backdropProps={{ forceRender: true }}
className={cn('min-w-[560px]', doShowDowngradeWarningModal && 'min-w-[640px]')}
>
<DialogCloseButton />
{doShowDowngradeWarningModal && (
<DowngradeWarningModal
onCancel={onCancel}
onJustDowngrade={handleConfirm}
onExcludeAndDowngrade={handleExcludeAndDownload}
/>
)}
{!doShowDowngradeWarningModal && (
<>
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`, { ns: 'plugin' })}
</DialogTitle>
<div className="mb-2 mt-3 text-text-secondary system-md-regular">
{t(`${i18nPrefix}.description`, { ns: 'plugin' })}
</div>
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
<Card
installed={uploadStep === UploadStep.installed}
payload={pluginManifestToCardPluginProps({
...originalPackageInfo.payload,
icon: icon!,
})}
className="w-full"
titleLeft={(
<>
<Badge className="mx-1" size="s" state={BadgeState.Warning}>
{`${originalPackageInfo.payload.version} -> ${targetPackageInfo.version}`}
</Badge>
</>
)}
/>
</div>
<div className="flex items-center justify-end gap-2 self-stretch pt-5">
{uploadStep === UploadStep.notStarted && (
<Button
onClick={handleCancel}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
)}
/>
</div>
<div className="flex items-center justify-end gap-2 self-stretch pt-5">
{uploadStep === UploadStep.notStarted && (
<Button
onClick={handleCancel}
variant="primary"
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
disabled={uploadStep === UploadStep.upgrading}
>
{t('operation.cancel', { ns: 'common' })}
{configBtnText}
</Button>
)}
<Button
variant="primary"
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
disabled={uploadStep === UploadStep.upgrading}
>
{configBtnText}
</Button>
</div>
</>
)}
</Modal>
</div>
</>
)}
</DialogContent>
</Dialog>
)
}
export default React.memo(UpdatePluginModal)

View File

@ -1,19 +1,16 @@
'use client'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import type { FC } from 'react'
import type { Placement } from '@/app/components/base/ui/placement'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { lt } from 'semver'
import Badge from '@/app/components/base/badge'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import useTimestamp from '@/hooks/use-timestamp'
import { useVersionListOfPlugin } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
@ -26,7 +23,8 @@ type Props = {
currentVersion: string
trigger: React.ReactNode
placement?: Placement
offset?: OffsetOptions
sideOffset?: number
alignOffset?: number
onSelect: ({
version,
unique_identifier,
@ -46,22 +44,14 @@ const PluginVersionPicker: FC<Props> = ({
currentVersion,
trigger,
placement = 'bottom-start',
offset = {
mainAxis: 4,
crossAxis: -16,
},
sideOffset = 4,
alignOffset = -16,
onSelect,
}) => {
const { t } = useTranslation()
const format = t('dateTimeFormat', { ns: 'appLog' }).split(' ')[0]
const { formatDate } = useTimestamp()
const handleTriggerClick = () => {
if (disabled)
return
onShowChange(true)
}
const { data: res } = useVersionListOfPlugin(pluginID)
const handleSelect = useCallback(({ version, unique_identifier, isDowngrade }: {
@ -76,49 +66,53 @@ const PluginVersionPicker: FC<Props> = ({
}, [currentVersion, onSelect, onShowChange])
return (
<PortalToFollowElem
placement={placement}
offset={offset}
<Popover
open={isShow}
onOpenChange={onShowChange}
onOpenChange={(open) => {
if (!disabled)
onShowChange(open)
}}
>
<PortalToFollowElemTrigger
<PopoverTrigger
disabled={disabled}
className={cn('inline-flex cursor-pointer items-center', disabled && 'cursor-default')}
onClick={handleTriggerClick}
>
{trigger}
</PortalToFollowElemTrigger>
</PopoverTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="relative w-[209px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
<div className="system-xs-medium-uppercase px-3 pb-0.5 pt-1 text-text-tertiary">
{t('detailPanel.switchVersion', { ns: 'plugin' })}
</div>
<div className="relative">
{res?.data.versions.map(version => (
<div
key={version.unique_identifier}
className={cn(
'flex h-7 cursor-pointer items-center gap-1 rounded-lg px-3 py-1 hover:bg-state-base-hover',
currentVersion === version.version && 'cursor-default opacity-30 hover:bg-transparent',
)}
onClick={() => handleSelect({
version: version.version,
unique_identifier: version.unique_identifier,
isDowngrade: lt(version.version, currentVersion),
})}
>
<div className="flex grow items-center">
<div className="system-sm-medium text-text-secondary">{version.version}</div>
{currentVersion === version.version && <Badge className="ml-1" text="CURRENT" />}
</div>
<div className="system-xs-regular shrink-0 text-text-tertiary">{formatDate(version.created_at, format)}</div>
</div>
))}
</div>
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
popupClassName="relative w-[209px] bg-components-panel-bg-blur p-1 backdrop-blur-sm"
>
<div className="px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase">
{t('detailPanel.switchVersion', { ns: 'plugin' })}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<div className="relative max-h-[224px] overflow-y-auto">
{res?.data.versions.map(version => (
<div
key={version.unique_identifier}
className={cn(
'flex h-7 cursor-pointer items-center gap-1 rounded-lg px-3 py-1 hover:bg-state-base-hover',
currentVersion === version.version && 'cursor-default opacity-30 hover:bg-transparent',
)}
onClick={() => handleSelect({
version: version.version,
unique_identifier: version.unique_identifier,
isDowngrade: lt(version.version, currentVersion),
})}
>
<div className="flex grow items-center">
<div className="text-text-secondary system-sm-medium">{version.version}</div>
{currentVersion === version.version && <Badge className="ml-1" text="CURRENT" />}
</div>
<div className="shrink-0 text-text-tertiary system-xs-regular">{formatDate(version.created_at, format)}</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -1,7 +1,9 @@
import type {
TagKey,
} from './constants'
import type { Plugin } from './types'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import {
categoryKeys,
tagKeys,
@ -14,3 +16,27 @@ export const getValidTagKeys = (tags: TagKey[]) => {
export const getValidCategoryKeys = (category?: string) => {
return categoryKeys.find(key => key === category)
}
const hasUrlProtocol = (value: string) => /^[a-z][a-z\d+.-]*:/i.test(value)
export const getPluginCardIconUrl = (
plugin: Pick<Plugin, 'from' | 'name' | 'org' | 'type'>,
icon: string | undefined,
tenantId: string,
) => {
if (!icon)
return ''
if (hasUrlProtocol(icon) || icon.startsWith('/'))
return icon
if (plugin.from === 'marketplace') {
const basePath = plugin.type === 'bundle' ? 'bundles' : 'plugins'
return `${MARKETPLACE_API_PREFIX}/${basePath}/${plugin.org}/${plugin.name}/icon`
}
if (!tenantId)
return icon
return `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${icon}`
}