mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 12:57:51 +08:00
refactor: enhance plugin management UI with error handling, improved rendering, and new components
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
@ -60,8 +60,8 @@ describe('ModelTrigger', () => {
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show status tooltip content when model is not active', async () => {
|
||||
const { container } = render(
|
||||
it('should show status badge when model is not active', () => {
|
||||
render(
|
||||
<ModelTrigger
|
||||
open={false}
|
||||
provider={makeModel()}
|
||||
@ -69,10 +69,7 @@ describe('ModelTrigger', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
|
||||
fireEvent.mouseEnter(tooltipTrigger)
|
||||
|
||||
expect(await screen.findByText('No Configure')).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show status icon when readonly', () => {
|
||||
@ -86,6 +83,6 @@ describe('ModelTrigger', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/modelProvider\.selector\.configureRequired/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,9 +2,11 @@ import type { DefaultModel, Model, ModelItem } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelFeatureEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../declarations'
|
||||
import PopupItem from './popup-item'
|
||||
|
||||
@ -33,6 +35,14 @@ vi.mock('../model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('./feature-icon', () => ({
|
||||
default: ({ feature }: { feature: string }) => <span data-testid="feature-icon">{feature}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
@ -45,6 +55,11 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: mockUseProviderContext,
|
||||
}))
|
||||
|
||||
const mockUseAppContext = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: mockUseAppContext,
|
||||
}))
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
@ -66,11 +81,24 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeProvider = (overrides: Record<string, unknown> = {}) => ({
|
||||
provider: 'openai',
|
||||
preferred_provider_type: PreferredProviderTypeEnum.custom,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
current_credential_name: 'my-api-key',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('PopupItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [{ provider: 'openai' }],
|
||||
modelProviders: [makeProvider()],
|
||||
})
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: { trial_credits: 200, trial_credits_used: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
@ -144,4 +172,66 @@ describe('PopupItem', () => {
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapsed state when clicking provider header', () => {
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('OpenAI'))
|
||||
|
||||
expect(screen.queryByText('GPT-4')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('OpenAI'))
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show credential name when using custom provider', () => {
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('my-api-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show configure required when no credential name', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider({
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.noConfigure,
|
||||
current_credential_name: '',
|
||||
},
|
||||
})],
|
||||
})
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show credits info when using system provider with remaining credits', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
})],
|
||||
})
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.aiCredits/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show credits exhausted when system provider has no credits', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [makeProvider({
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
})],
|
||||
})
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: { trial_credits: 100, trial_credits_used: 100 },
|
||||
})
|
||||
|
||||
render(<PopupItem model={makeModel()} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Model, ModelItem } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelFeatureEnum,
|
||||
@ -31,17 +31,13 @@ vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
|
||||
XCircle: ({ onClick }: { onClick?: () => void }) => (
|
||||
<button type="button" aria-label="clear-search" onClick={onClick} />
|
||||
),
|
||||
}))
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => mockLanguage,
|
||||
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
|
||||
}
|
||||
})
|
||||
|
||||
@ -49,6 +45,53 @@ vi.mock('./popup-item', () => ({
|
||||
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ modelProviders: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
|
||||
}))
|
||||
|
||||
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
const mockCheck = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
|
||||
default: () => ({ check: mockCheck }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: vi.fn(() => 'https://marketplace.example.com'),
|
||||
}))
|
||||
|
||||
vi.mock('../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils')>('../utils')
|
||||
return {
|
||||
...actual,
|
||||
MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'],
|
||||
providerIconMap: {
|
||||
'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>,
|
||||
'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>,
|
||||
},
|
||||
modelNameMap: {
|
||||
'test-openai': 'TestOpenAI',
|
||||
'test-anthropic': 'TestAnthropic',
|
||||
},
|
||||
providerKeyToPluginId: {
|
||||
'test-openai': 'langgenius/openai',
|
||||
'test-anthropic': 'langgenius/anthropic',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
@ -74,10 +117,11 @@ describe('Popup', () => {
|
||||
vi.clearAllMocks()
|
||||
mockLanguage = 'en_US'
|
||||
mockSupportFunctionCall.mockReturnValue(true)
|
||||
mockMarketplacePlugins.current = []
|
||||
})
|
||||
|
||||
it('should filter models by search and allow clearing search', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
@ -89,9 +133,11 @@ describe('Popup', () => {
|
||||
|
||||
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||
fireEvent.change(input, { target: { value: 'not-found' } })
|
||||
expect(screen.getByText('No model found for “not-found”')).toBeInTheDocument()
|
||||
expect(screen.getByText('No model found for \u201Cnot-found\u201D')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'clear-search' }))
|
||||
const clearIcon = container.querySelector('.i-custom-vender-solid-general-x-circle')
|
||||
expect(clearIcon).toBeInTheDocument()
|
||||
fireEvent.click(clearIcon!)
|
||||
expect((input as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
@ -100,7 +146,6 @@ describe('Popup', () => {
|
||||
makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }),
|
||||
]
|
||||
|
||||
// When tool-call support is missing, it should be filtered out.
|
||||
mockSupportFunctionCall.mockReturnValue(false)
|
||||
const { unmount } = render(
|
||||
<Popup
|
||||
@ -110,9 +155,8 @@ describe('Popup', () => {
|
||||
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
|
||||
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
|
||||
|
||||
// When tool-call support exists, the non-toolCall feature check should also pass.
|
||||
unmount()
|
||||
mockSupportFunctionCall.mockReturnValue(true)
|
||||
const { unmount: unmount2 } = render(
|
||||
@ -136,7 +180,6 @@ describe('Popup', () => {
|
||||
)
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
|
||||
// When features are missing, non-toolCall feature checks should fail.
|
||||
unmount3()
|
||||
render(
|
||||
<Popup
|
||||
@ -146,7 +189,7 @@ describe('Popup', () => {
|
||||
scopeFeatures={[ModelFeatureEnum.vision]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
|
||||
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match labels from other languages when current language key is missing', () => {
|
||||
@ -182,18 +225,150 @@ describe('Popup', () => {
|
||||
})
|
||||
|
||||
it('should open provider settings when clicking footer link', () => {
|
||||
const onHide = vi.fn()
|
||||
render(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={onHide}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: 'provider',
|
||||
})
|
||||
})
|
||||
|
||||
it('should show empty state when no providers are configured', () => {
|
||||
const onHide = vi.fn()
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={onHide}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: 'provider',
|
||||
})
|
||||
})
|
||||
|
||||
it('should render marketplace providers that are not installed', () => {
|
||||
render(
|
||||
<Popup
|
||||
modelList={[makeModel({ provider: 'test-openai' })]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.model.settingsLink'))
|
||||
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider\.selector\.fromMarketplace/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: 'provider',
|
||||
it('should toggle marketplace section collapse', () => {
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
|
||||
|
||||
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
|
||||
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should install plugin when clicking install button', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
|
||||
fireEvent.click(installButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle install failure gracefully', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
|
||||
fireEvent.click(installButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should not crash, install buttons should still be available
|
||||
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should run checkTaskStatus when not all_installed', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
|
||||
fireEvent.click(installButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
pluginUniqueIdentifier: 'langgenius/openai:1.0.0',
|
||||
})
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -263,14 +263,14 @@ const Popup: FC<PopupProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="sticky bottom-0 flex cursor-pointer items-center rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-4 py-2 text-text-accent-light-mode-only"
|
||||
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}}
|
||||
>
|
||||
<span className="system-xs-medium">{t('model.settingsLink', { ns: 'common' })}</span>
|
||||
<span className="i-ri-arrow-right-up-line ml-0.5 h-3 w-3" />
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
||||
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,348 @@
|
||||
import type { PluginInfoFromMarketPlace, PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PluginCategoryEnum, 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',
|
||||
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({ 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({ 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({ 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({ 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({ 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({ plugin_id: 'local-only-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/plugin\.task\.installFrom/)).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({ 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({ 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({ 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({ 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 detect github source with github in URL', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ 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 when onSuccess is called', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/p:1.0.0', '1.0.0'))
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/p' })}
|
||||
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('Success'))
|
||||
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should detect github source when id contains github keyword', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'my-github-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,202 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { 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',
|
||||
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 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,163 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { 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()}`,
|
||||
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,250 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { 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()}`,
|
||||
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,140 @@
|
||||
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 { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
import PluginItem from './plugin-item'
|
||||
|
||||
type PluginSource = 'marketplace' | 'github' | 'unknown'
|
||||
|
||||
function getPluginSource(pluginId: string): PluginSource {
|
||||
if (pluginId.includes('/') && !pluginId.startsWith('http'))
|
||||
return 'marketplace'
|
||||
if (pluginId.startsWith('http') || pluginId.includes('github'))
|
||||
return 'github'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
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 = getPluginSource(plugin.plugin_id)
|
||||
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 = {
|
||||
marketplace: 'task.errorMsg.marketplace',
|
||||
github: 'task.errorMsg.github',
|
||||
unknown: 'task.errorMsg.unknown',
|
||||
}[source] as 'task.errorMsg.marketplace'
|
||||
|
||||
const errorMsg = t(errorMsgKey, { ns: 'plugin' })
|
||||
|
||||
const renderAction = () => {
|
||||
if (source === 'marketplace') {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<Button variant="secondary" size="small" loading={isFetching} onClick={handleInstallFromMarketplace}>
|
||||
{t('task.installFromMarketplace', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (source === '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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorPluginItem
|
||||
@ -0,0 +1,59 @@
|
||||
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">
|
||||
<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
|
||||
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="system-md-regular truncate text-text-secondary">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Plugin Section Component
|
||||
const PluginSection: FC<PluginSectionProps> = ({
|
||||
title,
|
||||
count,
|
||||
plugins,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
defaultStatusText,
|
||||
statusClassName,
|
||||
headerAction,
|
||||
renderItemAction,
|
||||
}) => {
|
||||
if (plugins.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
|
||||
{title}
|
||||
{' '}
|
||||
(
|
||||
{count}
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={statusIcon}
|
||||
statusText={plugin.message || defaultStatusText}
|
||||
statusClassName={statusClassName}
|
||||
action={renderItemAction?.(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Plugin Task List Component
|
||||
const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
runningPlugins,
|
||||
successPlugins,
|
||||
@ -145,9 +39,9 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
|
||||
<span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
|
||||
}
|
||||
defaultStatusText={t('task.installing', { ns: 'plugin' })}
|
||||
defaultStatusText={t('task.installingHint', { ns: 'plugin' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -160,7 +54,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
|
||||
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
|
||||
}
|
||||
defaultStatusText={t('task.installed', { ns: 'plugin' })}
|
||||
statusClassName="text-text-success"
|
||||
@ -174,23 +68,15 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
onClearSingle={onClearSingle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Plugins Section */}
|
||||
{errorPlugins.length > 0 && (
|
||||
<PluginSection
|
||||
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
count={errorPlugins.length}
|
||||
plugins={errorPlugins}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
|
||||
}
|
||||
defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
statusClassName="text-text-destructive break-all"
|
||||
headerAction={(
|
||||
<>
|
||||
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
|
||||
{t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
@ -199,18 +85,19 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
>
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
renderItemAction={plugin => (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
>
|
||||
{t('operation.clear', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -5733,11 +5733,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -416,6 +416,7 @@
|
||||
"modelProvider.selector.incompatible": "Incompatible",
|
||||
"modelProvider.selector.incompatibleTip": "This model is not available in the current version. Please select another available model.",
|
||||
"modelProvider.selector.install": "Install",
|
||||
"modelProvider.selector.modelProviderSettings": "Model Provider Settings",
|
||||
"modelProvider.selector.noProviderConfigured": "No model provider configured",
|
||||
"modelProvider.selector.noProviderConfiguredDesc": "Browse Marketplace to install one, or configure providers in settings.",
|
||||
"modelProvider.selector.rerankTip": "Please set up the Rerank model",
|
||||
|
||||
@ -231,12 +231,18 @@
|
||||
"source.local": "Local Package File",
|
||||
"source.marketplace": "Marketplace",
|
||||
"task.clearAll": "Clear all",
|
||||
"task.errorMsg.github": "This plugin couldn't be installed automatically.\nPlease install it from GitHub.",
|
||||
"task.errorMsg.marketplace": "This plugin couldn't be installed automatically.\nPlease install it from the Marketplace.",
|
||||
"task.errorMsg.unknown": "This plugin couldn't be installed.\nThe plugin source couldn't be identified.",
|
||||
"task.errorPlugins": "Failed to Install Plugins",
|
||||
"task.installError": "{{errorLength}} plugins failed to install, click to view",
|
||||
"task.installFromGithub": "Install from GitHub",
|
||||
"task.installFromMarketplace": "Install from Marketplace",
|
||||
"task.installSuccess": "{{successLength}} plugins installed successfully",
|
||||
"task.installed": "Installed",
|
||||
"task.installedError": "{{errorLength}} plugins failed to install",
|
||||
"task.installing": "Installing plugins",
|
||||
"task.installingHint": "Installing... This may take a few minutes.",
|
||||
"task.installingWithError": "Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed",
|
||||
"task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
|
||||
@ -416,6 +416,7 @@
|
||||
"modelProvider.selector.incompatible": "不兼容",
|
||||
"modelProvider.selector.incompatibleTip": "该模型在当前版本中不可用,请选择其他可用模型。",
|
||||
"modelProvider.selector.install": "安装",
|
||||
"modelProvider.selector.modelProviderSettings": "模型供应商设置",
|
||||
"modelProvider.selector.noProviderConfigured": "未配置模型提供商",
|
||||
"modelProvider.selector.noProviderConfiguredDesc": "前往插件市场安装,或在设置中配置提供商。",
|
||||
"modelProvider.selector.rerankTip": "请设置 Rerank 模型",
|
||||
|
||||
@ -231,12 +231,18 @@
|
||||
"source.local": "本地插件",
|
||||
"source.marketplace": "Marketplace",
|
||||
"task.clearAll": "清除所有",
|
||||
"task.errorMsg.github": "此插件无法自动安装。\n请从 GitHub 安装。",
|
||||
"task.errorMsg.marketplace": "此插件无法自动安装。\n请从插件市场安装。",
|
||||
"task.errorMsg.unknown": "此插件无法安装。\n无法识别插件来源。",
|
||||
"task.errorPlugins": "安装失败的插件",
|
||||
"task.installError": "{{errorLength}} 个插件安装失败,点击查看",
|
||||
"task.installFromGithub": "从 GitHub 安装",
|
||||
"task.installFromMarketplace": "从插件市场安装",
|
||||
"task.installSuccess": "{{successLength}} 个插件安装成功",
|
||||
"task.installed": "已安装",
|
||||
"task.installedError": "{{errorLength}} 个插件安装失败",
|
||||
"task.installing": "正在安装插件",
|
||||
"task.installingHint": "正在安装……可能需要几分钟。",
|
||||
"task.installingWithError": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败",
|
||||
"task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功",
|
||||
"task.runningPlugins": "正在安装的插件",
|
||||
|
||||
Reference in New Issue
Block a user