mirror of
https://github.com/langgenius/dify.git
synced 2026-05-01 16:08:04 +08:00
refactor: enhance plugin management UI with error handling, improved rendering, and new components
This commit is contained in:
@ -0,0 +1,348 @@
|
||||
import type { PluginInfoFromMarketPlace, PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
|
||||
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
|
||||
import ErrorPluginItem from '../error-plugin-item'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, size }: { src: string, size: string }) => (
|
||||
<div data-testid="card-icon" data-src={src} data-size={size} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: ({ uniqueIdentifier, onClose, onSuccess }: { uniqueIdentifier: string, onClose: () => void, onSuccess: () => void }) => (
|
||||
<div data-testid="install-modal" data-uid={uniqueIdentifier}>
|
||||
<button onClick={onClose}>Close modal</button>
|
||||
<button onClick={onSuccess}>Success</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchPluginInfoFromMarketPlace: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetch = vi.mocked(fetchPluginInfoFromMarketPlace)
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
|
||||
|
||||
function createMarketplaceResponse(identifier: string, version: string) {
|
||||
return {
|
||||
data: {
|
||||
plugin: {
|
||||
category: PluginCategoryEnum.tool,
|
||||
latest_package_identifier: identifier,
|
||||
latest_version: version,
|
||||
} satisfies PluginInfoFromMarketPlace,
|
||||
version: { version },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: 'org/plugin:1.0.0',
|
||||
plugin_id: 'org/plugin',
|
||||
status: TaskStatus.failed,
|
||||
message: '',
|
||||
icon: 'icon.png',
|
||||
labels: { en_US: 'Test Plugin' } as PluginStatus['labels'],
|
||||
taskId: 'task-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ErrorPluginItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render plugin name', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error status icon', () => {
|
||||
const { container } = render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.i-ri-error-warning-fill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply destructive text styling', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const errorText = screen.getByText(/plugin\.task\.errorMsg\.marketplace/i)
|
||||
expect(errorText.closest('.text-text-destructive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Source detection and error messages', () => {
|
||||
it('should show marketplace error message for marketplace plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.errorMsg\.marketplace/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show github error message for github plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'https://github.com/user/repo' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.errorMsg\.github/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unknown error message for unknown source plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'local-only-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.errorMsg\.unknown/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show plugin.message when available instead of default error', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ message: 'Custom error occurred' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom error occurred')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Action buttons', () => {
|
||||
it('should show "Install from Marketplace" button for marketplace plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromMarketplace/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Install from GitHub" button for github plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'https://github.com/user/repo' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show action button for unknown source plugins', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'local-only-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/plugin\.task\.installFrom/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClear when clear button is clicked', () => {
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={onClear}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The clear button (×) is from PluginItem
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const clearButton = buttons.find(btn => !btn.textContent?.includes('plugin.task'))
|
||||
fireEvent.click(clearButton!)
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should fetch marketplace info and show install modal on button click', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/my-plugin:2.0.0', '2.0.0'))
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith({ org: 'org', name: 'my-plugin' })
|
||||
expect(screen.getByTestId('install-modal')).toHaveAttribute('data-uid', 'org/my-plugin:2.0.0')
|
||||
})
|
||||
|
||||
it('should close install modal when onClose is called', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/my-plugin:2.0.0', '2.0.0'))
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Close modal'))
|
||||
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should silently handle fetch failure', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/my-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not fetch when plugin_id has fewer than 2 parts', async () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'single-part' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Unknown source won't render the marketplace button, so nothing to click
|
||||
expect(screen.queryByText(/plugin\.task\.installFromMarketplace/)).not.toBeInTheDocument()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should detect github source with github in URL', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'http://github.com/user/repo' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close install modal when onSuccess is called', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMarketplaceResponse('org/p:1.0.0', '1.0.0'))
|
||||
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'org/p' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/plugin\.task\.installFromMarketplace/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Success'))
|
||||
|
||||
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should detect github source when id contains github keyword', () => {
|
||||
render(
|
||||
<ErrorPluginItem
|
||||
plugin={createPlugin({ plugin_id: 'my-github-plugin' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/plugin\.task\.installFromGithub/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,202 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import PluginItem from '../plugin-item'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, size }: { src: string, size: string }) => (
|
||||
<div data-testid="card-icon" data-src={src} data-size={size} />
|
||||
),
|
||||
}))
|
||||
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://example.com/icons/${icon}`)
|
||||
|
||||
const createPlugin = (overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: 'org/plugin:1.0.0',
|
||||
plugin_id: 'org/plugin',
|
||||
status: TaskStatus.running,
|
||||
message: '',
|
||||
icon: 'icon.png',
|
||||
labels: {
|
||||
en_US: 'Test Plugin',
|
||||
zh_Hans: '测试插件',
|
||||
} as PluginStatus['labels'],
|
||||
taskId: 'task-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('PluginItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render plugin name based on language', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span data-testid="status-icon" />}
|
||||
statusText="Installing..."
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render status text', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span data-testid="status-icon" />}
|
||||
statusText="Installing... please wait"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Installing... please wait')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render status icon', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span data-testid="status-icon" />}
|
||||
statusText="status"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass icon url to CardIcon', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin({ icon: 'my-icon.svg' })}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="status"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockGetIconUrl).toHaveBeenCalledWith('my-icon.svg')
|
||||
const cardIcon = screen.getByTestId('card-icon')
|
||||
expect(cardIcon).toHaveAttribute('data-src', 'https://example.com/icons/my-icon.svg')
|
||||
expect(cardIcon).toHaveAttribute('data-size', 'small')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom statusClassName', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="done"
|
||||
statusClassName="text-text-success"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('done').className).toContain('text-text-success')
|
||||
})
|
||||
|
||||
it('should apply default statusClassName when not provided', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="done"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('done').className).toContain('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render action when provided', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="status"
|
||||
action={<button>Install</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /install/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render action when not provided', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="status"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render zh-Hans label when language is zh_Hans', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="zh_Hans"
|
||||
statusIcon={<span />}
|
||||
statusText="status"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('测试插件')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should render clear button when onClear is provided', () => {
|
||||
const handleClear = vi.fn()
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="status"
|
||||
onClear={handleClear}
|
||||
/>,
|
||||
)
|
||||
|
||||
const clearButton = screen.getByRole('button')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not render clear button when onClear is not provided', () => {
|
||||
render(
|
||||
<PluginItem
|
||||
plugin={createPlugin()}
|
||||
getIconUrl={mockGetIconUrl}
|
||||
language="en_US"
|
||||
statusIcon={<span />}
|
||||
statusText="status"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,163 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import PluginSection from '../plugin-section'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, size }: { src: string, size: string }) => (
|
||||
<div data-testid="card-icon" data-src={src} data-size={size} />
|
||||
),
|
||||
}))
|
||||
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
|
||||
|
||||
const createPlugin = (id: string, name: string, message = ''): PluginStatus => ({
|
||||
plugin_unique_identifier: id,
|
||||
plugin_id: `org/${name.toLowerCase()}`,
|
||||
status: TaskStatus.running,
|
||||
message,
|
||||
icon: `${name.toLowerCase()}.png`,
|
||||
labels: { en_US: name, zh_Hans: name } as PluginStatus['labels'],
|
||||
taskId: 'task-1',
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
title: 'Installing plugins',
|
||||
count: 2,
|
||||
plugins: [createPlugin('p1', 'PluginA'), createPlugin('p2', 'PluginB')],
|
||||
getIconUrl: mockGetIconUrl,
|
||||
language: 'en_US' as const,
|
||||
statusIcon: <span data-testid="status-icon" />,
|
||||
defaultStatusText: 'Default status',
|
||||
}
|
||||
|
||||
describe('PluginSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render title and count', () => {
|
||||
render(<PluginSection {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/installing plugins/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/installing plugins/i).textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('should render all plugin items', () => {
|
||||
render(<PluginSection {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('PluginA')).toBeInTheDocument()
|
||||
expect(screen.getByText('PluginB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render status icons for each plugin', () => {
|
||||
render(<PluginSection {...defaultProps} />)
|
||||
|
||||
expect(screen.getAllByTestId('status-icon')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should return null when plugins array is empty', () => {
|
||||
const { container } = render(
|
||||
<PluginSection {...defaultProps} plugins={[]} />,
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should use plugin.message as statusText when available', () => {
|
||||
const plugins = [createPlugin('p1', 'PluginA', 'Custom message')]
|
||||
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
|
||||
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use defaultStatusText when plugin has no message', () => {
|
||||
const plugins = [createPlugin('p1', 'PluginA', '')]
|
||||
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
|
||||
|
||||
expect(screen.getByText('Default status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply statusClassName to items', () => {
|
||||
const plugins = [createPlugin('p1', 'PluginA')]
|
||||
render(
|
||||
<PluginSection
|
||||
{...defaultProps}
|
||||
plugins={plugins}
|
||||
count={1}
|
||||
statusClassName="text-text-success"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Default status').className).toContain('text-text-success')
|
||||
})
|
||||
|
||||
it('should render headerAction when provided', () => {
|
||||
render(
|
||||
<PluginSection
|
||||
{...defaultProps}
|
||||
headerAction={<button>Clear all</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render headerAction when not provided', () => {
|
||||
render(<PluginSection {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render item actions via renderItemAction', () => {
|
||||
render(
|
||||
<PluginSection
|
||||
{...defaultProps}
|
||||
renderItemAction={plugin => (
|
||||
<button>{`Action ${plugin.labels.en_US}`}</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /action plugina/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /action pluginb/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClearSingle with taskId and plugin identifier', () => {
|
||||
const onClearSingle = vi.fn()
|
||||
render(
|
||||
<PluginSection
|
||||
{...defaultProps}
|
||||
onClearSingle={onClearSingle}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Clear buttons are rendered when onClearSingle is provided
|
||||
const clearButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(clearButtons[0])
|
||||
|
||||
expect(onClearSingle).toHaveBeenCalledWith('task-1', 'p1')
|
||||
})
|
||||
|
||||
it('should not render clear buttons when onClearSingle is not provided', () => {
|
||||
render(<PluginSection {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single plugin', () => {
|
||||
const plugins = [createPlugin('p1', 'Solo')]
|
||||
render(<PluginSection {...defaultProps} plugins={plugins} count={1} />)
|
||||
|
||||
expect(screen.getByText('Solo')).toBeInTheDocument()
|
||||
expect(screen.getByText(/solo/i).closest('.max-h-\\[300px\\]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,250 @@
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TaskStatus } from '@/app/components/plugins/types'
|
||||
import PluginTaskList from '../plugin-task-list'
|
||||
|
||||
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
|
||||
default: ({ src, size }: { src: string, size: string }) => (
|
||||
<div data-testid="card-icon" data-src={src} data-size={size} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchPluginInfoFromMarketPlace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
const mockGetIconUrl = vi.fn((icon: string) => `https://icons/${icon}`)
|
||||
|
||||
const createPlugin = (id: string, name: string, overrides: Partial<PluginStatus> = {}): PluginStatus => ({
|
||||
plugin_unique_identifier: id,
|
||||
plugin_id: `org/${name.toLowerCase()}`,
|
||||
status: TaskStatus.running,
|
||||
message: '',
|
||||
icon: `${name.toLowerCase()}.png`,
|
||||
labels: { en_US: name } as PluginStatus['labels'],
|
||||
taskId: 'task-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const runningPlugins = [
|
||||
createPlugin('r1', 'OpenAI', { status: TaskStatus.running }),
|
||||
createPlugin('r2', 'Anthropic', { status: TaskStatus.running }),
|
||||
]
|
||||
|
||||
const successPlugins = [
|
||||
createPlugin('s1', 'Google', { status: TaskStatus.success }),
|
||||
]
|
||||
|
||||
const errorPlugins = [
|
||||
createPlugin('e1', 'DALLE', { status: TaskStatus.failed, plugin_id: 'org/dalle' }),
|
||||
]
|
||||
|
||||
describe('PluginTaskList', () => {
|
||||
const defaultProps = {
|
||||
runningPlugins: [] as PluginStatus[],
|
||||
successPlugins: [] as PluginStatus[],
|
||||
errorPlugins: [] as PluginStatus[],
|
||||
getIconUrl: mockGetIconUrl,
|
||||
onClearAll: vi.fn(),
|
||||
onClearErrors: vi.fn(),
|
||||
onClearSingle: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render empty container when no plugins', () => {
|
||||
const { container } = render(<PluginTaskList {...defaultProps} />)
|
||||
const wrapper = container.firstElementChild!
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
expect(wrapper.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render running section when running plugins exist', () => {
|
||||
const { container } = render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
|
||||
|
||||
// Header contains the title text
|
||||
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
|
||||
expect(headers).toHaveLength(1)
|
||||
expect(headers[0].textContent).toContain('plugin.task.installing')
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render success section when success plugins exist', () => {
|
||||
const { container } = render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
|
||||
|
||||
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
|
||||
expect(headers).toHaveLength(1)
|
||||
expect(headers[0].textContent).toContain('plugin.task.installed')
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error section when error plugins exist', () => {
|
||||
const { container } = render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
|
||||
|
||||
const headers = container.querySelectorAll('.system-sm-semibold-uppercase')
|
||||
expect(headers).toHaveLength(1)
|
||||
expect(headers[0].textContent).toContain('plugin.task.installedError')
|
||||
expect(screen.getByText('DALLE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all three sections simultaneously', () => {
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
runningPlugins={runningPlugins}
|
||||
successPlugins={successPlugins}
|
||||
errorPlugins={errorPlugins}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
expect(screen.getByText('DALLE')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clear actions', () => {
|
||||
it('should show Clear all button in success section', () => {
|
||||
render(<PluginTaskList {...defaultProps} successPlugins={successPlugins} />)
|
||||
|
||||
const clearButtons = screen.getAllByText(/plugin\.task\.clearAll/)
|
||||
expect(clearButtons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should call onClearAll when success section Clear all is clicked', () => {
|
||||
const onClearAll = vi.fn()
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
successPlugins={successPlugins}
|
||||
onClearAll={onClearAll}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/plugin\.task\.clearAll/))
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show Clear all button in error section', () => {
|
||||
render(<PluginTaskList {...defaultProps} errorPlugins={errorPlugins} />)
|
||||
|
||||
const clearButtons = screen.getAllByText(/plugin\.task\.clearAll/)
|
||||
expect(clearButtons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should call onClearErrors when error section Clear all is clicked', () => {
|
||||
const onClearErrors = vi.fn()
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
errorPlugins={errorPlugins}
|
||||
onClearErrors={onClearErrors}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/plugin\.task\.clearAll/))
|
||||
expect(onClearErrors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClearSingle from success section clear button', () => {
|
||||
const onClearSingle = vi.fn()
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
successPlugins={successPlugins}
|
||||
onClearSingle={onClearSingle}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The × close button from PluginItem (rendered inside PluginSection)
|
||||
const closeButtons = screen.getAllByRole('button')
|
||||
const clearItemBtn = closeButtons.find(btn => !btn.textContent?.includes('plugin.task'))
|
||||
if (clearItemBtn)
|
||||
fireEvent.click(clearItemBtn)
|
||||
|
||||
expect(onClearSingle).toHaveBeenCalledWith('task-1', 's1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Running section', () => {
|
||||
it('should not render clear buttons for running plugins', () => {
|
||||
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
|
||||
|
||||
// Running section has no headerAction and no onClearSingle
|
||||
expect(screen.queryByText(/plugin\.task\.clearAll/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show installing hint as status text', () => {
|
||||
render(<PluginTaskList {...defaultProps} runningPlugins={runningPlugins} />)
|
||||
|
||||
// defaultStatusText is t('task.installingHint', { ns: 'plugin' })
|
||||
const hintTexts = screen.getAllByText(/plugin\.task\.installingHint/)
|
||||
expect(hintTexts.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error section clear single', () => {
|
||||
it('should call onClearSingle from error item clear button', () => {
|
||||
const onClearSingle = vi.fn()
|
||||
render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
errorPlugins={errorPlugins}
|
||||
onClearSingle={onClearSingle}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the × close button inside error items (not the "Clear all" button)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const clearItemBtn = allButtons.find(btn =>
|
||||
!btn.textContent?.includes('plugin.task')
|
||||
&& !btn.textContent?.includes('installFrom'),
|
||||
)
|
||||
if (clearItemBtn)
|
||||
fireEvent.click(clearItemBtn)
|
||||
|
||||
expect(onClearSingle).toHaveBeenCalledWith('task-1', 'e1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render sections for empty plugin arrays', () => {
|
||||
const { container } = render(
|
||||
<PluginTaskList
|
||||
{...defaultProps}
|
||||
runningPlugins={[]}
|
||||
successPlugins={[]}
|
||||
errorPlugins={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('.w-\\[360px\\]')!.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render error section with multiple error plugins', () => {
|
||||
const multipleErrors = [
|
||||
createPlugin('e1', 'PluginA', { status: TaskStatus.failed, plugin_id: 'org/a' }),
|
||||
createPlugin('e2', 'PluginB', { status: TaskStatus.failed, plugin_id: 'https://github.com/b' }),
|
||||
createPlugin('e3', 'PluginC', { status: TaskStatus.failed, plugin_id: 'local-only' }),
|
||||
]
|
||||
|
||||
render(<PluginTaskList {...defaultProps} errorPlugins={multipleErrors} />)
|
||||
|
||||
expect(screen.getByText('PluginA')).toBeInTheDocument()
|
||||
expect(screen.getByText('PluginB')).toBeInTheDocument()
|
||||
expect(screen.getByText('PluginC')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import TaskStatusIndicator from '../task-status-indicator'
|
||||
|
||||
vi.mock('@/app/components/base/progress-bar/progress-circle', () => ({
|
||||
default: ({ percentage }: { percentage: number }) => (
|
||||
<div data-testid="progress-circle" data-percentage={percentage} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
||||
<div data-testid="tooltip" data-tip={popupContent}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({
|
||||
default: () => <span data-testid="downloading-icon" />,
|
||||
}))
|
||||
|
||||
const defaultProps = {
|
||||
tip: 'Installing plugins',
|
||||
isInstalling: false,
|
||||
isInstallingWithSuccess: false,
|
||||
isInstallingWithError: false,
|
||||
isSuccess: false,
|
||||
isFailed: false,
|
||||
successPluginsLength: 0,
|
||||
runningPluginsLength: 0,
|
||||
totalPluginsLength: 0,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
describe('TaskStatusIndicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} />)
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass tip to tooltip', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} tip="My tip" />)
|
||||
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-tip', 'My tip')
|
||||
})
|
||||
|
||||
it('should render install icon by default', () => {
|
||||
const { container } = render(<TaskStatusIndicator {...defaultProps} />)
|
||||
// RiInstallLine renders as svg
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('downloading-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Installing state', () => {
|
||||
it('should show downloading icon when isInstalling', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isInstalling />)
|
||||
expect(screen.getByTestId('downloading-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show downloading icon when isInstallingWithError', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} isInstallingWithError />)
|
||||
expect(screen.getByTestId('downloading-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show progress circle when isInstalling', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstalling
|
||||
successPluginsLength={2}
|
||||
totalPluginsLength={5}
|
||||
/>,
|
||||
)
|
||||
const progress = screen.getByTestId('progress-circle')
|
||||
expect(progress).toHaveAttribute('data-percentage', '40')
|
||||
})
|
||||
|
||||
it('should show progress circle when isInstallingWithSuccess', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstallingWithSuccess
|
||||
successPluginsLength={3}
|
||||
totalPluginsLength={4}
|
||||
/>,
|
||||
)
|
||||
const progress = screen.getByTestId('progress-circle')
|
||||
expect(progress).toHaveAttribute('data-percentage', '75')
|
||||
})
|
||||
|
||||
it('should show error progress circle when isInstallingWithError', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstallingWithError
|
||||
runningPluginsLength={1}
|
||||
totalPluginsLength={3}
|
||||
/>,
|
||||
)
|
||||
const progress = screen.getByTestId('progress-circle')
|
||||
expect(progress).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero totalPluginsLength without division error', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstalling
|
||||
totalPluginsLength={0}
|
||||
/>,
|
||||
)
|
||||
const progress = screen.getByTestId('progress-circle')
|
||||
expect(progress).toHaveAttribute('data-percentage', '0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Success state', () => {
|
||||
it('should show success icon when isSuccess', () => {
|
||||
const { container } = render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isSuccess
|
||||
successPluginsLength={3}
|
||||
totalPluginsLength={3}
|
||||
/>,
|
||||
)
|
||||
// RiCheckboxCircleFill is rendered as svg with text-text-success
|
||||
const successIcon = container.querySelector('.text-text-success')
|
||||
expect(successIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success icon when successPlugins > 0 and no running plugins', () => {
|
||||
const { container } = render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
successPluginsLength={2}
|
||||
runningPluginsLength={0}
|
||||
totalPluginsLength={2}
|
||||
/>,
|
||||
)
|
||||
const successIcon = container.querySelector('.text-text-success')
|
||||
expect(successIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show success icon during installing states', () => {
|
||||
const { container } = render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstalling
|
||||
successPluginsLength={1}
|
||||
runningPluginsLength={1}
|
||||
totalPluginsLength={2}
|
||||
/>,
|
||||
)
|
||||
// Progress circle shown instead of success icon
|
||||
expect(screen.getByTestId('progress-circle')).toBeInTheDocument()
|
||||
expect(container.querySelector('.text-text-success')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Failed state', () => {
|
||||
it('should show error icon when isFailed', () => {
|
||||
const { container } = render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isFailed
|
||||
totalPluginsLength={2}
|
||||
/>,
|
||||
)
|
||||
const errorIcon = container.querySelector('.text-text-destructive')
|
||||
expect(errorIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply destructive styling when isFailed', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isFailed
|
||||
totalPluginsLength={1}
|
||||
/>,
|
||||
)
|
||||
const button = document.getElementById('plugin-task-trigger')!
|
||||
expect(button.className).toContain('bg-state-destructive-hover')
|
||||
})
|
||||
|
||||
it('should apply destructive styling when isInstallingWithError', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isInstallingWithError
|
||||
totalPluginsLength={2}
|
||||
/>,
|
||||
)
|
||||
const button = document.getElementById('plugin-task-trigger')!
|
||||
expect(button.className).toContain('bg-state-destructive-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<TaskStatusIndicator {...defaultProps} onClick={onClick} />)
|
||||
|
||||
const button = document.getElementById('plugin-task-trigger')!
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should apply cursor-pointer for interactive states', () => {
|
||||
render(
|
||||
<TaskStatusIndicator
|
||||
{...defaultProps}
|
||||
isSuccess
|
||||
successPluginsLength={1}
|
||||
totalPluginsLength={1}
|
||||
/>,
|
||||
)
|
||||
const button = document.getElementById('plugin-task-trigger')!
|
||||
expect(button.className).toContain('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should not show any badge indicators when all flags are false', () => {
|
||||
render(<TaskStatusIndicator {...defaultProps} />)
|
||||
expect(screen.queryByTestId('progress-circle')).not.toBeInTheDocument()
|
||||
const button = document.getElementById('plugin-task-trigger')!
|
||||
// No success or error icons in the badge area
|
||||
expect(button.querySelector('.text-text-success')).not.toBeInTheDocument()
|
||||
expect(button.querySelector('.text-text-destructive')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,140 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Plugin, PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { fetchPluginInfoFromMarketPlace } from '@/service/plugins'
|
||||
import PluginItem from './plugin-item'
|
||||
|
||||
type PluginSource = 'marketplace' | 'github' | 'unknown'
|
||||
|
||||
function getPluginSource(pluginId: string): PluginSource {
|
||||
if (pluginId.includes('/') && !pluginId.startsWith('http'))
|
||||
return 'marketplace'
|
||||
if (pluginId.startsWith('http') || pluginId.includes('github'))
|
||||
return 'github'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
type ErrorPluginItemProps = {
|
||||
plugin: PluginStatus
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, language, onClear }) => {
|
||||
const { t } = useTranslation()
|
||||
const source = getPluginSource(plugin.plugin_id)
|
||||
const [showInstallModal, setShowInstallModal] = useState(false)
|
||||
const [installPayload, setInstallPayload] = useState<{ uniqueIdentifier: string, manifest: Plugin } | null>(null)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
|
||||
const handleInstallFromMarketplace = useCallback(async () => {
|
||||
const parts = plugin.plugin_id.split('/')
|
||||
if (parts.length < 2)
|
||||
return
|
||||
const [org, name] = parts
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const response = await fetchPluginInfoFromMarketPlace({ org, name })
|
||||
const info = response.data.plugin
|
||||
const manifest: Plugin = {
|
||||
plugin_id: plugin.plugin_id,
|
||||
type: info.category as Plugin['type'],
|
||||
category: info.category,
|
||||
name,
|
||||
org,
|
||||
version: info.latest_version,
|
||||
latest_version: info.latest_version,
|
||||
latest_package_identifier: info.latest_package_identifier,
|
||||
label: plugin.labels,
|
||||
brief: {},
|
||||
description: {},
|
||||
icon: plugin.icon,
|
||||
verified: true,
|
||||
introduction: '',
|
||||
repository: '',
|
||||
install_count: 0,
|
||||
endpoint: { settings: [] },
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: { authorized_category: 'langgenius' },
|
||||
from: 'marketplace',
|
||||
}
|
||||
setInstallPayload({ uniqueIdentifier: info.latest_package_identifier, manifest })
|
||||
setShowInstallModal(true)
|
||||
}
|
||||
catch {
|
||||
// silently fail
|
||||
}
|
||||
finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [plugin.plugin_id, plugin.labels, plugin.icon])
|
||||
|
||||
const errorMsgKey = {
|
||||
marketplace: 'task.errorMsg.marketplace',
|
||||
github: 'task.errorMsg.github',
|
||||
unknown: 'task.errorMsg.unknown',
|
||||
}[source] as 'task.errorMsg.marketplace'
|
||||
|
||||
const errorMsg = t(errorMsgKey, { ns: 'plugin' })
|
||||
|
||||
const renderAction = () => {
|
||||
if (source === 'marketplace') {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<Button variant="secondary" size="small" loading={isFetching} onClick={handleInstallFromMarketplace}>
|
||||
{t('task.installFromMarketplace', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (source === 'github') {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<Button variant="secondary" size="small">
|
||||
{t('task.installFromGithub', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginItem
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={(
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-components-panel-bg bg-components-panel-bg">
|
||||
<span className="i-ri-error-warning-fill h-4 w-4 text-text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
statusText={(
|
||||
<span className="whitespace-pre-line">
|
||||
{plugin.message || errorMsg}
|
||||
</span>
|
||||
)}
|
||||
statusClassName="text-text-destructive"
|
||||
action={renderAction()}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{showInstallModal && installPayload && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={installPayload.uniqueIdentifier}
|
||||
manifest={installPayload.manifest}
|
||||
onClose={() => setShowInstallModal(false)}
|
||||
onSuccess={() => setShowInstallModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorPluginItem
|
||||
@ -0,0 +1,59 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import CardIcon from '@/app/components/plugins/card/base/card-icon'
|
||||
|
||||
export type PluginItemProps = {
|
||||
plugin: PluginStatus
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
statusIcon: ReactNode
|
||||
statusText: ReactNode
|
||||
statusClassName?: string
|
||||
action?: ReactNode
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
const PluginItem: FC<PluginItemProps> = ({
|
||||
plugin,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
statusText,
|
||||
statusClassName,
|
||||
action,
|
||||
onClear,
|
||||
}) => {
|
||||
return (
|
||||
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="relative shrink-0">
|
||||
<CardIcon
|
||||
size="small"
|
||||
src={getIconUrl(plugin.icon)}
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 z-10">
|
||||
{statusIcon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 grow flex-col gap-0.5 px-1">
|
||||
<div className="truncate text-text-secondary system-sm-medium">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{onClear && (
|
||||
<button
|
||||
className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-state-base-hover-alt group-hover/item:flex"
|
||||
onClick={onClear}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginItem
|
||||
@ -0,0 +1,67 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import PluginItem from './plugin-item'
|
||||
|
||||
export type PluginSectionProps = {
|
||||
title: string
|
||||
count: number
|
||||
plugins: PluginStatus[]
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
statusIcon: ReactNode
|
||||
defaultStatusText: ReactNode
|
||||
statusClassName?: string
|
||||
headerAction?: ReactNode
|
||||
renderItemAction?: (plugin: PluginStatus) => ReactNode
|
||||
onClearSingle?: (taskId: string, pluginId: string) => void
|
||||
}
|
||||
|
||||
const PluginSection: FC<PluginSectionProps> = ({
|
||||
title,
|
||||
count,
|
||||
plugins,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
defaultStatusText,
|
||||
statusClassName,
|
||||
headerAction,
|
||||
renderItemAction,
|
||||
onClearSingle,
|
||||
}) => {
|
||||
if (plugins.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
|
||||
{title}
|
||||
{' '}
|
||||
(
|
||||
{count}
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={statusIcon}
|
||||
statusText={plugin.message || defaultStatusText}
|
||||
statusClassName={statusClassName}
|
||||
action={renderItemAction?.(plugin)}
|
||||
onClear={onClearSingle
|
||||
? () => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginSection
|
||||
@ -1,39 +1,10 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiLoaderLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CardIcon from '@/app/components/plugins/card/base/card-icon'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
// Types
|
||||
type PluginItemProps = {
|
||||
plugin: PluginStatus
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
statusIcon: ReactNode
|
||||
statusText: string
|
||||
statusClassName?: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
type PluginSectionProps = {
|
||||
title: string
|
||||
count: number
|
||||
plugins: PluginStatus[]
|
||||
getIconUrl: (icon: string) => string
|
||||
language: Locale
|
||||
statusIcon: ReactNode
|
||||
defaultStatusText: string
|
||||
statusClassName?: string
|
||||
headerAction?: ReactNode
|
||||
renderItemAction?: (plugin: PluginStatus) => ReactNode
|
||||
}
|
||||
import ErrorPluginItem from './error-plugin-item'
|
||||
import PluginSection from './plugin-section'
|
||||
|
||||
type PluginTaskListProps = {
|
||||
runningPlugins: PluginStatus[]
|
||||
@ -45,83 +16,6 @@ type PluginTaskListProps = {
|
||||
onClearSingle: (taskId: string, pluginId: string) => void
|
||||
}
|
||||
|
||||
// Plugin Item Component
|
||||
const PluginItem: FC<PluginItemProps> = ({
|
||||
plugin,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
statusText,
|
||||
statusClassName,
|
||||
action,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="relative mr-2 flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
{statusIcon}
|
||||
<CardIcon
|
||||
size="tiny"
|
||||
src={getIconUrl(plugin.icon)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="system-md-regular truncate text-text-secondary">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Plugin Section Component
|
||||
const PluginSection: FC<PluginSectionProps> = ({
|
||||
title,
|
||||
count,
|
||||
plugins,
|
||||
getIconUrl,
|
||||
language,
|
||||
statusIcon,
|
||||
defaultStatusText,
|
||||
statusClassName,
|
||||
headerAction,
|
||||
renderItemAction,
|
||||
}) => {
|
||||
if (plugins.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="system-sm-semibold-uppercase sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary">
|
||||
{title}
|
||||
{' '}
|
||||
(
|
||||
{count}
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={statusIcon}
|
||||
statusText={plugin.message || defaultStatusText}
|
||||
statusClassName={statusClassName}
|
||||
action={renderItemAction?.(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Plugin Task List Component
|
||||
const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
runningPlugins,
|
||||
successPlugins,
|
||||
@ -145,9 +39,9 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiLoaderLine className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 animate-spin text-text-accent" />
|
||||
<span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin text-text-accent" />
|
||||
}
|
||||
defaultStatusText={t('task.installing', { ns: 'plugin' })}
|
||||
defaultStatusText={t('task.installingHint', { ns: 'plugin' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -160,7 +54,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiCheckboxCircleFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-success" />
|
||||
<span className="i-ri-checkbox-circle-fill h-3.5 w-3.5 text-text-success" />
|
||||
}
|
||||
defaultStatusText={t('task.installed', { ns: 'plugin' })}
|
||||
statusClassName="text-text-success"
|
||||
@ -174,23 +68,15 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
onClearSingle={onClearSingle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Plugins Section */}
|
||||
{errorPlugins.length > 0 && (
|
||||
<PluginSection
|
||||
title={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
count={errorPlugins.length}
|
||||
plugins={errorPlugins}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
statusIcon={
|
||||
<RiErrorWarningFill className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 text-text-destructive" />
|
||||
}
|
||||
defaultStatusText={t('task.installError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
statusClassName="text-text-destructive break-all"
|
||||
headerAction={(
|
||||
<>
|
||||
<div className="sticky top-0 flex h-7 items-center justify-between px-2 pt-1 text-text-secondary system-sm-semibold-uppercase">
|
||||
{t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
@ -199,18 +85,19 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
>
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
)}
|
||||
renderItemAction={plugin => (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
>
|
||||
{t('operation.clear', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
plugin={plugin}
|
||||
getIconUrl={getIconUrl}
|
||||
language={language}
|
||||
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user