mirror of
https://github.com/langgenius/dify.git
synced 2026-05-01 07:58:02 +08:00
Merge main HEAD (segment 5) into sandboxed-agent-rebase
Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files. Preserve sandbox/agent/collaboration features while adopting main's UI refactorings (Dialog/AlertDialog/Popover), model provider updates, and enterprise features. Made-with: Cursor
This commit is contained in:
@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,7 +2,7 @@ import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DeprecationNotice from '../deprecation-notice'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
|
||||
<a data-testid="link" href={href}>{children}</a>
|
||||
),
|
||||
|
||||
@ -2,10 +2,10 @@ import type { FC } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiAlertFill } from '@remixicon/react'
|
||||
import { camelCase } from 'es-toolkit/string'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type DeprecationNoticeProps = {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -16,34 +16,6 @@ vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const va = parseVersion(a)
|
||||
const vb = parseVersion(b)
|
||||
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
||||
const diff = (va[i] || 0) - (vb[i] || 0)
|
||||
if (diff > 0)
|
||||
return 1
|
||||
if (diff < 0)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number)
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0)
|
||||
if (diff !== 0)
|
||||
return diff
|
||||
}
|
||||
return 0
|
||||
}).pop()!
|
||||
},
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
globalThis.fetch = mockFetch
|
||||
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -5,12 +5,12 @@ import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins'
|
||||
import { isEqualOrLaterThanVersion } from '@/utils/semver'
|
||||
import Card from '../../../card'
|
||||
import { TaskStatus } from '../../../types'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
@ -111,7 +111,7 @@ const Installed: FC<Props> = ({
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0')
|
||||
return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version])
|
||||
|
||||
return (
|
||||
|
||||
@ -5,11 +5,11 @@ import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import { isEqualOrLaterThanVersion } from '@/utils/semver'
|
||||
import Card from '../../../card'
|
||||
// import { RiInformation2Line } from '@remixicon/react'
|
||||
import { TaskStatus } from '../../../types'
|
||||
@ -126,7 +126,7 @@ const Installed: FC<Props> = ({
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!pluginDeclaration || !langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0')
|
||||
return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.current_version, pluginDeclaration])
|
||||
|
||||
const { canInstall } = useInstallPluginLimit({ ...payload, from: 'marketplace' })
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -63,7 +63,7 @@ export const getMarketplacePluginsByCollectionId = async (
|
||||
params: {
|
||||
collectionId,
|
||||
},
|
||||
body: query,
|
||||
body: query ?? {},
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -76,16 +76,16 @@ afterAll(() => {
|
||||
|
||||
// Mock portal components for controlled positioning in tests
|
||||
// Use React context to properly scope open state per portal instance (for nested portals)
|
||||
const _PortalOpenContext = React.createContext(false)
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
// Context reference shared across mock components
|
||||
let sharedContext: React.Context<boolean> | null = null
|
||||
|
||||
// Lazily get or create the context
|
||||
const getContext = (): React.Context<boolean> => {
|
||||
if (!sharedContext)
|
||||
sharedContext = React.createContext(false)
|
||||
if (!sharedContext) {
|
||||
const PortalOpenContext = React.createContext(false)
|
||||
sharedContext = PortalOpenContext
|
||||
}
|
||||
return sharedContext
|
||||
}
|
||||
|
||||
@ -725,6 +725,39 @@ describe('AppPicker', () => {
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should reset loadingRef when the picker closes before the debounce timeout finishes', () => {
|
||||
const onLoadMore = vi.fn()
|
||||
const { rerender } = render(
|
||||
<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />,
|
||||
)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<AppPicker {...defaultProps} isShow={false} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
|
||||
rerender(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should reset loadingRef when the picker unmounts before the debounce timeout finishes', () => {
|
||||
const onLoadMore = vi.fn()
|
||||
const { unmount } = render(
|
||||
<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />,
|
||||
)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
@ -1539,7 +1572,7 @@ describe('AppSelector', () => {
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should manage isLoadingMore state during load more', () => {
|
||||
it('should render correctly during load more setup', () => {
|
||||
mockHasNextPage = true
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
|
||||
@ -1739,7 +1772,7 @@ describe('AppSelector', () => {
|
||||
expect(mockFetchNextPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isLoadingMore and reset after delay in handleLoadMore', async () => {
|
||||
it('should avoid duplicate fetches while the picker debounce is active', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
@ -1756,34 +1789,15 @@ describe('AppSelector', () => {
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Try to trigger again immediately - should be blocked by isLoadingMore
|
||||
// Try to trigger again immediately - should be blocked by AppPicker loadingRef
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// Still only one call due to isLoadingMore
|
||||
// Still only one call due to the picker-level debounce
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// This verifies the debounce logic is working - multiple calls are blocked
|
||||
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when isLoadingMore is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
// Open portals
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// Trigger intersection - this starts loading
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should skip handleLoadMore when isFetchingNextPage is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = true // This will block the handleLoadMore
|
||||
@ -1821,89 +1835,7 @@ describe('AppSelector', () => {
|
||||
// fetchNextPage should NOT be called because hasMore is false
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early from handleLoadMore when isLoadingMore is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
// Make fetchNextPage slow to keep isLoadingMore true
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000)))
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// First call starts loading
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call should return early due to isLoadingMore
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// Still only 1 call because isLoadingMore blocks it
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// Trigger load more
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// Wait for fetchNextPage to complete and setTimeout to fire
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(350) // Past the 300ms setTimeout
|
||||
})
|
||||
|
||||
// Should be able to load more again
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// This might trigger another fetch if loadingRef also reset
|
||||
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should reset isLoadingMore after fetchNextPage completes with setTimeout', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
// Open portals
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// Trigger first intersection
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance timer past the 300ms setTimeout in finally block
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
// Also advance past the loadingRef timeout in AppPicker (500ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
// Verify component is still rendered correctly
|
||||
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Change Handling', () => {
|
||||
it('should handle form change with image file', () => {
|
||||
const onSelect = vi.fn()
|
||||
@ -2284,7 +2216,7 @@ describe('AppSelector Integration', () => {
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set isLoadingMore to false after fetchNextPage completes', async () => {
|
||||
it('should stay stable after fetchNextPage completes', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
@ -2293,16 +2225,10 @@ describe('AppSelector Integration', () => {
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
|
||||
// Advance timers past the 300ms delay
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when conditions prevent it', () => {
|
||||
// isLoadingMore would be true internally
|
||||
mockHasNextPage = false
|
||||
mockIsFetchingNextPage = true
|
||||
|
||||
|
||||
@ -51,9 +51,30 @@ const AppPicker: FC<Props> = ({
|
||||
onSearchChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
const observerTargetRef = useRef<HTMLDivElement>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const loadingRef = useRef(false)
|
||||
const loadingResetTimerIdRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const retimeLoadingReset = useCallback((timerId?: number) => {
|
||||
if (loadingResetTimerIdRef.current !== undefined)
|
||||
globalThis.clearTimeout(loadingResetTimerIdRef.current)
|
||||
|
||||
loadingResetTimerIdRef.current = timerId
|
||||
}, [])
|
||||
|
||||
const resetLoadingState = useCallback(() => {
|
||||
retimeLoadingReset()
|
||||
loadingRef.current = false
|
||||
}, [retimeLoadingReset])
|
||||
|
||||
const disconnectObserver = useCallback(() => {
|
||||
if (!observerRef.current)
|
||||
return
|
||||
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
|
||||
const target = entries[0]
|
||||
@ -62,27 +83,27 @@ const AppPicker: FC<Props> = ({
|
||||
|
||||
loadingRef.current = true
|
||||
onLoadMore()
|
||||
// Reset loading state
|
||||
setTimeout(() => {
|
||||
retimeLoadingReset(window.setTimeout(() => {
|
||||
loadingRef.current = false
|
||||
}, 500)
|
||||
}, [hasMore, isLoading, onLoadMore])
|
||||
retimeLoadingReset()
|
||||
}, 500))
|
||||
}, [hasMore, isLoading, onLoadMore, retimeLoadingReset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow) {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
resetLoadingState()
|
||||
disconnectObserver()
|
||||
return
|
||||
}
|
||||
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (!observerTarget.current)
|
||||
if (!observerTargetRef.current)
|
||||
return
|
||||
|
||||
disconnectObserver()
|
||||
|
||||
// Create new observer
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
@ -90,12 +111,12 @@ const AppPicker: FC<Props> = ({
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observerRef.current.observe(observerTarget.current)
|
||||
observerRef.current.observe(observerTargetRef.current)
|
||||
}
|
||||
|
||||
// Set up MutationObserver to watch DOM changes
|
||||
mutationObserver = new MutationObserver((_mutations) => {
|
||||
if (observerTarget.current) {
|
||||
if (observerTargetRef.current) {
|
||||
setupIntersectionObserver()
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
@ -108,17 +129,15 @@ const AppPicker: FC<Props> = ({
|
||||
})
|
||||
|
||||
// If element exists, set up IntersectionObserver directly
|
||||
if (observerTarget.current)
|
||||
if (observerTargetRef.current)
|
||||
setupIntersectionObserver()
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
resetLoadingState()
|
||||
disconnectObserver()
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
}, [isShow, handleIntersection])
|
||||
}, [disconnectObserver, handleIntersection, isShow, resetLoadingState])
|
||||
|
||||
const getAppType = (app: App) => {
|
||||
switch (app.mode) {
|
||||
@ -191,7 +210,7 @@ const AppPicker: FC<Props> = ({
|
||||
<div className="shrink-0 text-text-tertiary system-2xs-medium-uppercase">{getAppType(app)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={observerTarget} className="h-4 w-full">
|
||||
<div ref={observerTargetRef} className="h-4 w-full">
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="text-sm text-gray-500">{t('loading', { ns: 'common' })}</div>
|
||||
|
||||
@ -47,9 +47,8 @@ const AppSelector: FC<Props> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShow, onShowChange] = useState(false)
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -97,25 +96,16 @@ const AppSelector: FC<Props> = ({
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (isLoadingMore || isFetchingNextPage || !hasMore)
|
||||
if (isFetchingNextPage || !hasMore)
|
||||
return
|
||||
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
await fetchNextPage()
|
||||
}
|
||||
finally {
|
||||
// Add a small delay to ensure state updates are complete
|
||||
setTimeout(() => {
|
||||
setIsLoadingMore(false)
|
||||
}, 300)
|
||||
}
|
||||
}, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage])
|
||||
await fetchNextPage()
|
||||
}, [fetchNextPage, hasMore, isFetchingNextPage])
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled)
|
||||
return
|
||||
onShowChange(true)
|
||||
setIsShow(true)
|
||||
}
|
||||
|
||||
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
|
||||
@ -157,7 +147,7 @@ const AppSelector: FC<Props> = ({
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
onOpenChange={setIsShow}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
@ -187,7 +177,7 @@ const AppSelector: FC<Props> = ({
|
||||
onSelect={handleSelectApp}
|
||||
scope={scope || 'all'}
|
||||
apps={appsForPicker}
|
||||
isLoading={isLoading || isLoadingMore || isFetchingNextPage}
|
||||
isLoading={isLoading || isFetchingNextPage}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
searchText={searchText}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
])
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Import component after mocks
|
||||
@ -17,44 +18,73 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock PortalSelect component
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
PortalSelect: ({
|
||||
const MockSelectContext = React.createContext<{
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}>({
|
||||
value: '',
|
||||
onValueChange: () => {},
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/ui/select', () => ({
|
||||
Select: ({
|
||||
value,
|
||||
items,
|
||||
onSelect,
|
||||
triggerClassName,
|
||||
popupClassName,
|
||||
popupInnerClassName,
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
value: string
|
||||
items: Array<{ value: string, name: string }>
|
||||
onSelect: (item: { value: string }) => void
|
||||
triggerClassName?: string
|
||||
popupClassName?: string
|
||||
popupInnerClassName?: string
|
||||
onValueChange: (value: string) => void
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<div
|
||||
data-testid="portal-select"
|
||||
data-value={value}
|
||||
data-trigger-class={triggerClassName}
|
||||
data-popup-class={popupClassName}
|
||||
data-popup-inner-class={popupInnerClassName}
|
||||
>
|
||||
<span data-testid="selected-value">{value}</span>
|
||||
<div data-testid="items-container">
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`select-item-${item.value}`}
|
||||
onClick={() => onSelect({ value: item.value })}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MockSelectContext.Provider value={{ value, onValueChange }}>
|
||||
<div data-testid="select-root">{children}</div>
|
||||
</MockSelectContext.Provider>
|
||||
),
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
'children': React.ReactNode
|
||||
'className'?: string
|
||||
'data-testid'?: string
|
||||
}) => (
|
||||
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: () => {
|
||||
const { value } = React.useContext(MockSelectContext)
|
||||
return <span data-testid="selected-value">{value}</span>
|
||||
},
|
||||
SelectContent: ({
|
||||
children,
|
||||
popupClassName,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupClassName?: string
|
||||
}) => (
|
||||
<div data-testid="select-content" data-popup-class={popupClassName}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value: string
|
||||
}) => {
|
||||
const { onValueChange } = React.useContext(MockSelectContext)
|
||||
return (
|
||||
<button
|
||||
data-testid={`select-item-${value}`}
|
||||
onClick={() => onValueChange(value)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
@ -139,7 +169,7 @@ describe('TTSParamsPanel', () => {
|
||||
expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two PortalSelect components', () => {
|
||||
it('should render two Select components', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -147,7 +177,7 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
const selects = screen.getAllByTestId('select-root')
|
||||
expect(selects).toHaveLength(2)
|
||||
})
|
||||
|
||||
@ -159,8 +189,8 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
|
||||
const values = screen.getAllByTestId('selected-value')
|
||||
expect(values[0]).toHaveTextContent('zh-Hans')
|
||||
})
|
||||
|
||||
it('should render voice select with correct value', () => {
|
||||
@ -171,8 +201,8 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[1]).toHaveAttribute('data-value', 'echo')
|
||||
const values = screen.getAllByTestId('selected-value')
|
||||
expect(values[1]).toHaveTextContent('echo')
|
||||
})
|
||||
|
||||
it('should only show supported languages in language select', () => {
|
||||
@ -205,7 +235,7 @@ describe('TTSParamsPanel', () => {
|
||||
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props', () => {
|
||||
it('should apply trigger className to PortalSelect', () => {
|
||||
it('should apply trigger className to SelectTrigger', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -213,12 +243,11 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
|
||||
expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
|
||||
expect(screen.getByTestId('tts-language-select-trigger')).toHaveAttribute('data-class', 'w-full')
|
||||
expect(screen.getByTestId('tts-voice-select-trigger')).toHaveAttribute('data-class', 'w-full')
|
||||
})
|
||||
|
||||
it('should apply popup className to PortalSelect', () => {
|
||||
it('should apply popup className to SelectContent', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -226,22 +255,9 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
|
||||
expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
|
||||
})
|
||||
|
||||
it('should apply popup inner className to PortalSelect', () => {
|
||||
// Arrange
|
||||
const props = createDefaultProps()
|
||||
|
||||
// Act
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
|
||||
expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
|
||||
const contents = screen.getAllByTestId('select-content')
|
||||
expect(contents[0]).toHaveAttribute('data-popup-class', 'w-[354px]')
|
||||
expect(contents[1]).toHaveAttribute('data-popup-class', 'w-[354px]')
|
||||
})
|
||||
})
|
||||
|
||||
@ -411,10 +427,8 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert - no voice items (except language items)
|
||||
const voiceSelects = screen.getAllByTestId('portal-select')
|
||||
// Second select is voice select, should have no voice items in items-container
|
||||
const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
|
||||
expect(voiceItemsContainer?.children).toHaveLength(0)
|
||||
expect(screen.getAllByTestId('select-content')[1].children).toHaveLength(0)
|
||||
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle currentModel with single voice', () => {
|
||||
@ -443,8 +457,8 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-value', '')
|
||||
const values = screen.getAllByTestId('selected-value')
|
||||
expect(values[0]).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle empty voice value', () => {
|
||||
@ -455,8 +469,8 @@ describe('TTSParamsPanel', () => {
|
||||
render(<TTSParamsPanel {...props} />)
|
||||
|
||||
// Assert
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[1]).toHaveAttribute('data-value', '')
|
||||
const values = screen.getAllByTestId('selected-value')
|
||||
expect(values[1]).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle many voices', () => {
|
||||
@ -514,14 +528,14 @@ describe('TTSParamsPanel', () => {
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<TTSParamsPanel {...props} />)
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[0]).toHaveAttribute('data-value', 'en-US')
|
||||
const values = screen.getAllByTestId('selected-value')
|
||||
expect(values[0]).toHaveTextContent('en-US')
|
||||
|
||||
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
|
||||
|
||||
// Assert
|
||||
const updatedSelects = screen.getAllByTestId('portal-select')
|
||||
expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
|
||||
const updatedValues = screen.getAllByTestId('selected-value')
|
||||
expect(updatedValues[0]).toHaveTextContent('zh-Hans')
|
||||
})
|
||||
|
||||
it('should update when voice prop changes', () => {
|
||||
@ -530,14 +544,14 @@ describe('TTSParamsPanel', () => {
|
||||
|
||||
// Act
|
||||
const { rerender } = render(<TTSParamsPanel {...props} />)
|
||||
const selects = screen.getAllByTestId('portal-select')
|
||||
expect(selects[1]).toHaveAttribute('data-value', 'alloy')
|
||||
const values = screen.getAllByTestId('selected-value')
|
||||
expect(values[1]).toHaveTextContent('alloy')
|
||||
|
||||
rerender(<TTSParamsPanel {...props} voice="echo" />)
|
||||
|
||||
// Assert
|
||||
const updatedSelects = screen.getAllByTestId('portal-select')
|
||||
expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
|
||||
const updatedValues = screen.getAllByTestId('selected-value')
|
||||
expect(updatedValues[1]).toHaveTextContent('echo')
|
||||
})
|
||||
|
||||
it('should update voice list when currentModel changes', () => {
|
||||
|
||||
@ -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,
|
||||
@ -31,7 +31,6 @@ import TTSParamsPanel from './tts-params-panel'
|
||||
|
||||
export type ModelParameterModalProps = {
|
||||
popupClassName?: string
|
||||
portalToFollowElemContentClassName?: string
|
||||
isAdvancedMode: boolean
|
||||
value: any
|
||||
setModel: (model: any) => void
|
||||
@ -44,7 +43,6 @@ export type ModelParameterModalProps = {
|
||||
|
||||
const ModelParameterModal: FC<ModelParameterModalProps> = ({
|
||||
popupClassName,
|
||||
portalToFollowElemContentClassName,
|
||||
isAdvancedMode,
|
||||
value,
|
||||
setModel,
|
||||
@ -114,15 +112,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 +178,94 @@ 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}
|
||||
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('mb-1 flex h-6 items-center text-text-secondary system-sm-semibold')}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
currentModel: any
|
||||
@ -12,6 +11,8 @@ type Props = {
|
||||
onChange: (language: string, voice: string) => void
|
||||
}
|
||||
|
||||
const supportedLanguages = languages.filter(item => item.supported)
|
||||
|
||||
const TTSParamsPanel = ({
|
||||
currentModel,
|
||||
language,
|
||||
@ -19,11 +20,11 @@ const TTSParamsPanel = ({
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const voiceList = useMemo(() => {
|
||||
const voiceList = useMemo<Array<{ label: string, value: string }>>(() => {
|
||||
if (!currentModel)
|
||||
return []
|
||||
return currentModel.model_properties.voices.map((item: { mode: any }) => ({
|
||||
...item,
|
||||
return currentModel.model_properties.voices.map((item: { mode: string, name: string }) => ({
|
||||
label: item.name,
|
||||
value: item.mode,
|
||||
}))
|
||||
}, [currentModel])
|
||||
@ -39,27 +40,57 @@ const TTSParamsPanel = ({
|
||||
<div className="mb-1 flex items-center py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<PortalSelect
|
||||
triggerClassName="h-8"
|
||||
popupClassName={cn('z-[1000]')}
|
||||
popupInnerClassName={cn('w-[354px]')}
|
||||
<Select
|
||||
value={language}
|
||||
items={languages.filter(item => item.supported)}
|
||||
onSelect={item => setLanguage(item.value as string)}
|
||||
/>
|
||||
onValueChange={(value) => {
|
||||
if (value == null)
|
||||
return
|
||||
setLanguage(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
data-testid="tts-language-select-trigger"
|
||||
aria-label={t('voice.voiceSettings.language', { ns: 'appDebug' })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-[354px]">
|
||||
{supportedLanguages.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<PortalSelect
|
||||
triggerClassName="h-8"
|
||||
popupClassName={cn('z-[1000]')}
|
||||
popupInnerClassName={cn('w-[354px]')}
|
||||
<Select
|
||||
value={voice}
|
||||
items={voiceList}
|
||||
onSelect={item => setVoice(item.value as string)}
|
||||
/>
|
||||
onValueChange={(value) => {
|
||||
if (value == null)
|
||||
return
|
||||
setVoice(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
data-testid="tts-voice-select-trigger"
|
||||
aria-label={t('voice.voiceSettings.voice', { ns: 'appDebug' })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-[354px]">
|
||||
{voiceList.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular hover:bg-state-base-hover"
|
||||
>
|
||||
{t('detailPanel.operation.info', { ns: 'plugin' })}
|
||||
</div>
|
||||
)}
|
||||
{source === PluginSource.github && (
|
||||
<div
|
||||
onClick={() => {
|
||||
onCheckVersion()
|
||||
handleTrigger()
|
||||
}}
|
||||
className="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular 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="flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary system-md-regular 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="cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary system-md-regular 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)
|
||||
|
||||
@ -4,7 +4,8 @@ import { DeleteConfirm } from '../delete-confirm'
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockToast = vi.fn()
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../use-subscription-list', () => ({
|
||||
useSubscriptionList: () => ({ refetch: mockRefetch }),
|
||||
@ -14,11 +15,17 @@ vi.mock('@/service/use-triggers', () => ({
|
||||
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: { type: string, message: string }) => mockToast(args),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
|
||||
return {
|
||||
...actual,
|
||||
toast: {
|
||||
...actual.toast,
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -42,7 +49,7 @@ describe('DeleteConfirm', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled()
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
expect(mockToastError).toHaveBeenCalledWith('pluginTrigger.subscription.list.item.actions.deleteConfirm.confirmInputWarning')
|
||||
})
|
||||
|
||||
it('should allow deletion after matching input name', () => {
|
||||
@ -87,6 +94,6 @@ describe('DeleteConfirm', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ }))
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'network error' }))
|
||||
expect(mockToastError).toHaveBeenCalledWith('network error')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => {
|
||||
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
|
||||
onSuccess()
|
||||
})
|
||||
const builder = createMockSubscriptionBuilder()
|
||||
|
||||
render(<CommonCreateModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateBuilder).toHaveBeenCalled()
|
||||
})
|
||||
render(<CommonCreateModal {...defaultProps} builder={builder} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useDeleteTriggerSubscription } from '@/service/use-triggers'
|
||||
import { useSubscriptionList } from './use-subscription-list'
|
||||
|
||||
@ -23,58 +31,65 @@ export const DeleteConfirm = (props: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputName, setInputName] = useState('')
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (isDeleting)
|
||||
return
|
||||
|
||||
if (!open)
|
||||
onClose(false)
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
if (workflowsInUse > 0 && inputName !== currentName) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`${tPrefix}.confirmInputWarning`, { ns: 'pluginTrigger' }),
|
||||
// temporarily
|
||||
className: 'z-[10000001]',
|
||||
})
|
||||
toast.error(t(`${tPrefix}.confirmInputWarning`, { ns: 'pluginTrigger' }))
|
||||
return
|
||||
}
|
||||
deleteSubscription(currentId, {
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t(`${tPrefix}.success`, { ns: 'pluginTrigger', name: currentName }),
|
||||
className: 'z-[10000001]',
|
||||
})
|
||||
toast.success(t(`${tPrefix}.success`, { ns: 'pluginTrigger', name: currentName }))
|
||||
refetch?.()
|
||||
onClose(true)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error?.message || t(`${tPrefix}.error`, { ns: 'pluginTrigger', name: currentName }),
|
||||
className: 'z-[10000001]',
|
||||
})
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : t(`${tPrefix}.error`, { ns: 'pluginTrigger', name: currentName }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Confirm
|
||||
title={t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })}
|
||||
confirmText={t(`${tPrefix}.confirm`, { ns: 'pluginTrigger' })}
|
||||
content={workflowsInUse > 0
|
||||
? (
|
||||
<>
|
||||
{t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse })}
|
||||
<div className="mb-2 mt-6 text-text-secondary system-sm-medium">{t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })}</div>
|
||||
<AlertDialog open={isShow} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pb-4 pt-6">
|
||||
<AlertDialogTitle title={t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })} className="w-full truncate text-text-primary title-2xl-semi-bold">
|
||||
{t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
|
||||
{workflowsInUse > 0
|
||||
? t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse })
|
||||
: t(`${tPrefix}.content`, { ns: 'pluginTrigger' })}
|
||||
</AlertDialogDescription>
|
||||
{workflowsInUse > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 text-text-secondary system-sm-medium">
|
||||
{t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })}
|
||||
</div>
|
||||
<Input
|
||||
value={inputName}
|
||||
onChange={e => setInputName(e.target.value)}
|
||||
placeholder={t(`${tPrefix}.confirmInputPlaceholder`, { ns: 'pluginTrigger', name: currentName })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: t(`${tPrefix}.content`, { ns: 'pluginTrigger' })}
|
||||
isShow={isShow}
|
||||
isLoading={isDeleting}
|
||||
isDisabled={isDeleting}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={() => onClose(false)}
|
||||
maskClosable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton disabled={isDeleting}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={onConfirm}>
|
||||
{t(`${tPrefix}.confirm`, { ns: 'pluginTrigger' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import type { FC } from 'react'
|
||||
import type { Node } from 'reactflow'
|
||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@ -16,6 +15,7 @@ import {
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
ToolAuthorizationSection,
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
|
||||
import { API_PREFIX } from '@/config'
|
||||
@ -20,6 +19,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isEqualOrLaterThanVersion } from '@/utils/semver'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import Badge from '../../base/badge'
|
||||
import { Github } from '../../base/icons/src/public/common'
|
||||
@ -71,7 +71,7 @@ const PluginItem: FC<Props> = ({
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0')
|
||||
return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0')
|
||||
}, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version])
|
||||
|
||||
const isDeprecated = useMemo(() => {
|
||||
|
||||
@ -8,6 +8,8 @@ import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||
import PluginPageWithContext from '../index'
|
||||
|
||||
let mockEnableMarketplace = true
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchManifestFromMarketPlace: vi.fn(),
|
||||
@ -31,7 +33,7 @@ vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
enable_marketplace: mockEnableMarketplace,
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
@ -138,6 +140,7 @@ const createDefaultProps = (): PluginPageProps => ({
|
||||
describe('PluginPage Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableMarketplace = true
|
||||
// Reset to default mock values
|
||||
vi.mocked(usePluginInstallation).mockReturnValue([
|
||||
{ packageId: null, bundleInfo: null },
|
||||
@ -630,18 +633,7 @@ describe('PluginPage Component', () => {
|
||||
})
|
||||
|
||||
it('should handle marketplace disabled', () => {
|
||||
// Mock marketplace disabled
|
||||
vi.mock('@/context/global-public-context', async () => ({
|
||||
useGlobalPublicStore: vi.fn((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: false,
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
}),
|
||||
}))
|
||||
|
||||
mockEnableMarketplace = false
|
||||
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -21,6 +20,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||
import Link from '@/next/link'
|
||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||
import { sleep } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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="truncate text-text-secondary system-md-regular">
|
||||
{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="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-[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>
|
||||
)
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -104,50 +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) => {
|
||||
const parseVersion = (v: string) => v.split('.').map(Number)
|
||||
const [major1, minor1, patch1] = parseVersion(v1)
|
||||
const [major2, minor2, patch2] = parseVersion(v2)
|
||||
if (major1 !== major2)
|
||||
return major1 < major2
|
||||
if (minor1 !== minor2)
|
||||
return minor1 < minor2
|
||||
return patch1 < patch2
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Data Factories
|
||||
// ================================
|
||||
@ -247,7 +203,6 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
describe('update-plugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpen = false
|
||||
mockCheck.mockResolvedValue({ status: TaskStatus.success })
|
||||
})
|
||||
|
||||
@ -732,8 +687,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 +725,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 +948,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 +966,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 +974,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 +1003,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 +1015,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 +1117,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 +1126,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 +1192,7 @@ describe('update-plugin', () => {
|
||||
onShowChange: vi.fn(),
|
||||
pluginID: 'test',
|
||||
currentVersion: '1.0.0',
|
||||
trigger: <button>Select</button>,
|
||||
trigger: <span>Select</span>,
|
||||
onSelect: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
|
||||
@ -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="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>
|
||||
</>
|
||||
<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)
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
'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'
|
||||
import { isEarlierThanVersion } from '@/utils/semver'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
@ -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="px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase">
|
||||
{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="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
|
||||
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: isEarlierThanVersion(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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,30 @@ 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 | { content: string, background: string } | undefined,
|
||||
tenantId: string,
|
||||
) => {
|
||||
if (!icon)
|
||||
return ''
|
||||
|
||||
if (typeof icon === 'object')
|
||||
return icon
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user