mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +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 type { Model, ModelItem } from '../declarations'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import {
|
import {
|
||||||
ConfigurationMethodEnum,
|
ConfigurationMethodEnum,
|
||||||
ModelStatusEnum,
|
ModelStatusEnum,
|
||||||
@ -60,8 +60,8 @@ describe('ModelTrigger', () => {
|
|||||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show status tooltip content when model is not active', async () => {
|
it('should show status badge when model is not active', () => {
|
||||||
const { container } = render(
|
render(
|
||||||
<ModelTrigger
|
<ModelTrigger
|
||||||
open={false}
|
open={false}
|
||||||
provider={makeModel()}
|
provider={makeModel()}
|
||||||
@ -69,10 +69,7 @@ describe('ModelTrigger', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
|
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
|
||||||
fireEvent.mouseEnter(tooltipTrigger)
|
|
||||||
|
|
||||||
expect(await screen.findByText('No Configure')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not show status icon when readonly', () => {
|
it('should not show status icon when readonly', () => {
|
||||||
@ -86,6 +83,6 @@ describe('ModelTrigger', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
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 { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import {
|
import {
|
||||||
ConfigurationMethodEnum,
|
ConfigurationMethodEnum,
|
||||||
|
CustomConfigurationStatusEnum,
|
||||||
ModelFeatureEnum,
|
ModelFeatureEnum,
|
||||||
ModelStatusEnum,
|
ModelStatusEnum,
|
||||||
ModelTypeEnum,
|
ModelTypeEnum,
|
||||||
|
PreferredProviderTypeEnum,
|
||||||
} from '../declarations'
|
} from '../declarations'
|
||||||
import PopupItem from './popup-item'
|
import PopupItem from './popup-item'
|
||||||
|
|
||||||
@ -33,6 +35,14 @@ vi.mock('../model-name', () => ({
|
|||||||
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
|
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())
|
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('@/context/modal-context', () => ({
|
vi.mock('@/context/modal-context', () => ({
|
||||||
useModalContext: () => ({
|
useModalContext: () => ({
|
||||||
@ -45,6 +55,11 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
useProviderContext: mockUseProviderContext,
|
useProviderContext: mockUseProviderContext,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const mockUseAppContext = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('@/context/app-context', () => ({
|
||||||
|
useAppContext: mockUseAppContext,
|
||||||
|
}))
|
||||||
|
|
||||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||||
@ -66,11 +81,24 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
|||||||
...overrides,
|
...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', () => {
|
describe('PopupItem', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockUseProviderContext.mockReturnValue({
|
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()
|
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 type { Model, ModelItem } from '../declarations'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import {
|
import {
|
||||||
ConfigurationMethodEnum,
|
ConfigurationMethodEnum,
|
||||||
ModelFeatureEnum,
|
ModelFeatureEnum,
|
||||||
@ -31,17 +31,13 @@ vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
|
const mockMarketplacePlugins = vi.hoisted(() => ({ current: [] as Array<{ plugin_id: string, latest_package_identifier: string }> }))
|
||||||
XCircle: ({ onClick }: { onClick?: () => void }) => (
|
|
||||||
<button type="button" aria-label="clear-search" onClick={onClick} />
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../hooks', async () => {
|
vi.mock('../hooks', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useLanguage: () => mockLanguage,
|
useLanguage: () => mockLanguage,
|
||||||
|
useMarketplaceAllPlugins: () => ({ plugins: mockMarketplacePlugins.current }),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -49,6 +45,53 @@ vi.mock('./popup-item', () => ({
|
|||||||
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
|
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 => ({
|
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||||
@ -74,10 +117,11 @@ describe('Popup', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockLanguage = 'en_US'
|
mockLanguage = 'en_US'
|
||||||
mockSupportFunctionCall.mockReturnValue(true)
|
mockSupportFunctionCall.mockReturnValue(true)
|
||||||
|
mockMarketplacePlugins.current = []
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter models by search and allow clearing search', () => {
|
it('should filter models by search and allow clearing search', () => {
|
||||||
render(
|
const { container } = render(
|
||||||
<Popup
|
<Popup
|
||||||
modelList={[makeModel()]}
|
modelList={[makeModel()]}
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
@ -89,9 +133,11 @@ describe('Popup', () => {
|
|||||||
|
|
||||||
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||||
fireEvent.change(input, { target: { value: 'not-found' } })
|
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('')
|
expect((input as HTMLInputElement).value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -100,7 +146,6 @@ describe('Popup', () => {
|
|||||||
makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }),
|
makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }),
|
||||||
]
|
]
|
||||||
|
|
||||||
// When tool-call support is missing, it should be filtered out.
|
|
||||||
mockSupportFunctionCall.mockReturnValue(false)
|
mockSupportFunctionCall.mockReturnValue(false)
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<Popup
|
<Popup
|
||||||
@ -110,9 +155,8 @@ describe('Popup', () => {
|
|||||||
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
|
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()
|
unmount()
|
||||||
mockSupportFunctionCall.mockReturnValue(true)
|
mockSupportFunctionCall.mockReturnValue(true)
|
||||||
const { unmount: unmount2 } = render(
|
const { unmount: unmount2 } = render(
|
||||||
@ -136,7 +180,6 @@ describe('Popup', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||||
|
|
||||||
// When features are missing, non-toolCall feature checks should fail.
|
|
||||||
unmount3()
|
unmount3()
|
||||||
render(
|
render(
|
||||||
<Popup
|
<Popup
|
||||||
@ -146,7 +189,7 @@ describe('Popup', () => {
|
|||||||
scopeFeatures={[ModelFeatureEnum.vision]}
|
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', () => {
|
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', () => {
|
it('should open provider settings when clicking footer link', () => {
|
||||||
|
const onHide = vi.fn()
|
||||||
render(
|
render(
|
||||||
<Popup
|
<Popup
|
||||||
modelList={[makeModel()]}
|
modelList={[makeModel()]}
|
||||||
onSelect={vi.fn()}
|
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()}
|
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({
|
it('should toggle marketplace section collapse', () => {
|
||||||
payload: 'provider',
|
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>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
onHide()
|
onHide()
|
||||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="system-xs-medium">{t('model.settingsLink', { ns: 'common' })}</span>
|
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
||||||
<span className="i-ri-arrow-right-up-line ml-0.5 h-3 w-3" />
|
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
||||||
</div>
|
</div>
|
||||||
</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 { 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 { useTranslation } from 'react-i18next'
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import CardIcon from '@/app/components/plugins/card/base/card-icon'
|
|
||||||
import { useGetLanguage } from '@/context/i18n'
|
import { useGetLanguage } from '@/context/i18n'
|
||||||
|
import ErrorPluginItem from './error-plugin-item'
|
||||||
// Types
|
import PluginSection from './plugin-section'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginTaskListProps = {
|
type PluginTaskListProps = {
|
||||||
runningPlugins: PluginStatus[]
|
runningPlugins: PluginStatus[]
|
||||||
@ -45,83 +16,6 @@ type PluginTaskListProps = {
|
|||||||
onClearSingle: (taskId: string, pluginId: string) => void
|
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> = ({
|
const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||||
runningPlugins,
|
runningPlugins,
|
||||||
successPlugins,
|
successPlugins,
|
||||||
@ -145,9 +39,9 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
|||||||
getIconUrl={getIconUrl}
|
getIconUrl={getIconUrl}
|
||||||
language={language}
|
language={language}
|
||||||
statusIcon={
|
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}
|
getIconUrl={getIconUrl}
|
||||||
language={language}
|
language={language}
|
||||||
statusIcon={
|
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' })}
|
defaultStatusText={t('task.installed', { ns: 'plugin' })}
|
||||||
statusClassName="text-text-success"
|
statusClassName="text-text-success"
|
||||||
@ -174,23 +68,15 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
|||||||
{t('task.clearAll', { ns: 'plugin' })}
|
{t('task.clearAll', { ns: 'plugin' })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
onClearSingle={onClearSingle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Plugins Section */}
|
{/* Error Plugins Section */}
|
||||||
{errorPlugins.length > 0 && (
|
{errorPlugins.length > 0 && (
|
||||||
<PluginSection
|
<>
|
||||||
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
|
||||||
count={errorPlugins.length}
|
{t('task.installedError', { ns: 'plugin', errorLength: 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={(
|
|
||||||
<Button
|
<Button
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
size="small"
|
size="small"
|
||||||
@ -199,18 +85,19 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
|||||||
>
|
>
|
||||||
{t('task.clearAll', { ns: 'plugin' })}
|
{t('task.clearAll', { ns: 'plugin' })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
renderItemAction={plugin => (
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
<Button
|
{errorPlugins.map(plugin => (
|
||||||
className="shrink-0"
|
<ErrorPluginItem
|
||||||
size="small"
|
key={plugin.plugin_unique_identifier}
|
||||||
variant="ghost"
|
plugin={plugin}
|
||||||
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
getIconUrl={getIconUrl}
|
||||||
>
|
language={language}
|
||||||
{t('operation.clear', { ns: 'common' })}
|
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||||
</Button>
|
/>
|
||||||
)}
|
))}
|
||||||
/>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5733,11 +5733,6 @@
|
|||||||
"count": 1
|
"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": {
|
"app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@ -416,6 +416,7 @@
|
|||||||
"modelProvider.selector.incompatible": "Incompatible",
|
"modelProvider.selector.incompatible": "Incompatible",
|
||||||
"modelProvider.selector.incompatibleTip": "This model is not available in the current version. Please select another available model.",
|
"modelProvider.selector.incompatibleTip": "This model is not available in the current version. Please select another available model.",
|
||||||
"modelProvider.selector.install": "Install",
|
"modelProvider.selector.install": "Install",
|
||||||
|
"modelProvider.selector.modelProviderSettings": "Model Provider Settings",
|
||||||
"modelProvider.selector.noProviderConfigured": "No model provider configured",
|
"modelProvider.selector.noProviderConfigured": "No model provider configured",
|
||||||
"modelProvider.selector.noProviderConfiguredDesc": "Browse Marketplace to install one, or configure providers in settings.",
|
"modelProvider.selector.noProviderConfiguredDesc": "Browse Marketplace to install one, or configure providers in settings.",
|
||||||
"modelProvider.selector.rerankTip": "Please set up the Rerank model",
|
"modelProvider.selector.rerankTip": "Please set up the Rerank model",
|
||||||
|
|||||||
@ -231,12 +231,18 @@
|
|||||||
"source.local": "Local Package File",
|
"source.local": "Local Package File",
|
||||||
"source.marketplace": "Marketplace",
|
"source.marketplace": "Marketplace",
|
||||||
"task.clearAll": "Clear all",
|
"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.errorPlugins": "Failed to Install Plugins",
|
||||||
"task.installError": "{{errorLength}} plugins failed to install, click to view",
|
"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.installSuccess": "{{successLength}} plugins installed successfully",
|
||||||
"task.installed": "Installed",
|
"task.installed": "Installed",
|
||||||
"task.installedError": "{{errorLength}} plugins failed to install",
|
"task.installedError": "{{errorLength}} plugins failed to install",
|
||||||
"task.installing": "Installing plugins",
|
"task.installing": "Installing plugins",
|
||||||
|
"task.installingHint": "Installing... This may take a few minutes.",
|
||||||
"task.installingWithError": "Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed",
|
"task.installingWithError": "Installing {{installingLength}} plugins, {{successLength}} success, {{errorLength}} failed",
|
||||||
"task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.",
|
"task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.",
|
||||||
"task.runningPlugins": "Installing Plugins",
|
"task.runningPlugins": "Installing Plugins",
|
||||||
|
|||||||
@ -416,6 +416,7 @@
|
|||||||
"modelProvider.selector.incompatible": "不兼容",
|
"modelProvider.selector.incompatible": "不兼容",
|
||||||
"modelProvider.selector.incompatibleTip": "该模型在当前版本中不可用,请选择其他可用模型。",
|
"modelProvider.selector.incompatibleTip": "该模型在当前版本中不可用,请选择其他可用模型。",
|
||||||
"modelProvider.selector.install": "安装",
|
"modelProvider.selector.install": "安装",
|
||||||
|
"modelProvider.selector.modelProviderSettings": "模型供应商设置",
|
||||||
"modelProvider.selector.noProviderConfigured": "未配置模型提供商",
|
"modelProvider.selector.noProviderConfigured": "未配置模型提供商",
|
||||||
"modelProvider.selector.noProviderConfiguredDesc": "前往插件市场安装,或在设置中配置提供商。",
|
"modelProvider.selector.noProviderConfiguredDesc": "前往插件市场安装,或在设置中配置提供商。",
|
||||||
"modelProvider.selector.rerankTip": "请设置 Rerank 模型",
|
"modelProvider.selector.rerankTip": "请设置 Rerank 模型",
|
||||||
|
|||||||
@ -231,12 +231,18 @@
|
|||||||
"source.local": "本地插件",
|
"source.local": "本地插件",
|
||||||
"source.marketplace": "Marketplace",
|
"source.marketplace": "Marketplace",
|
||||||
"task.clearAll": "清除所有",
|
"task.clearAll": "清除所有",
|
||||||
|
"task.errorMsg.github": "此插件无法自动安装。\n请从 GitHub 安装。",
|
||||||
|
"task.errorMsg.marketplace": "此插件无法自动安装。\n请从插件市场安装。",
|
||||||
|
"task.errorMsg.unknown": "此插件无法安装。\n无法识别插件来源。",
|
||||||
"task.errorPlugins": "安装失败的插件",
|
"task.errorPlugins": "安装失败的插件",
|
||||||
"task.installError": "{{errorLength}} 个插件安装失败,点击查看",
|
"task.installError": "{{errorLength}} 个插件安装失败,点击查看",
|
||||||
|
"task.installFromGithub": "从 GitHub 安装",
|
||||||
|
"task.installFromMarketplace": "从插件市场安装",
|
||||||
"task.installSuccess": "{{successLength}} 个插件安装成功",
|
"task.installSuccess": "{{successLength}} 个插件安装成功",
|
||||||
"task.installed": "已安装",
|
"task.installed": "已安装",
|
||||||
"task.installedError": "{{errorLength}} 个插件安装失败",
|
"task.installedError": "{{errorLength}} 个插件安装失败",
|
||||||
"task.installing": "正在安装插件",
|
"task.installing": "正在安装插件",
|
||||||
|
"task.installingHint": "正在安装……可能需要几分钟。",
|
||||||
"task.installingWithError": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败",
|
"task.installingWithError": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功,{{errorLength}} 安装失败",
|
||||||
"task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功",
|
"task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功",
|
||||||
"task.runningPlugins": "正在安装的插件",
|
"task.runningPlugins": "正在安装的插件",
|
||||||
|
|||||||
Reference in New Issue
Block a user