From 16cdf381dc6c26a5b15000e60873b1ffb87f6234 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 26 Dec 2025 14:18:05 +0800 Subject: [PATCH] feat: implement plugin task management UI and tests - Added a new component to manage and display plugin tasks, including running, success, and error states. - Created separate components for rendering sections of running, success, and error plugins. - Introduced a component for handling task interactions with visual indicators. - Implemented a custom hook to manage the state and logic of the plugin task panel. - Added comprehensive tests for the new components and their interactions, ensuring proper rendering and functionality across various states. --- .../plugin-page/plugin-tasks/index.spec.tsx | 1358 +++++++++++++++++ .../plugin-page/plugin-tasks/index.tsx | 313 +--- .../plugin-tasks/plugin-task-list.tsx | 202 +++ .../plugin-tasks/plugin-task-trigger.tsx | 94 ++ .../plugin-tasks/use-plugin-task-panel.ts | 94 ++ 5 files changed, 1789 insertions(+), 272 deletions(-) create mode 100644 web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx create mode 100644 web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx create mode 100644 web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx new file mode 100644 index 0000000000..a387830ff1 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.spec.tsx @@ -0,0 +1,1358 @@ +import type { PluginStatus } from '@/app/components/plugins/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TaskStatus } from '@/app/components/plugins/types' + +// ============================================================================ +// Import Components After Mocks +// ============================================================================ + +import PluginTasks from './index' +import { + ErrorPluginsSection, + RunningPluginsSection, + SuccessPluginsSection, +} from './plugin-task-list' +import PluginTaskTrigger from './plugin-task-trigger' + +// ============================================================================ +// Mock Setup +// ============================================================================ + +// Stable translation function to avoid re-render issues +const stableT = (key: string, params?: Record) => { + if (params) { + let result = key + Object.entries(params).forEach(([k, v]) => { + result = result.replace(new RegExp(`{{${k}}}`, 'g'), String(v)) + }) + return result + } + return key +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: stableT, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})) + +// Mock language context - prevent es-toolkit import error +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en-US', +})) + +// Mock icon utility +vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: (icon: string) => icon || 'default-icon.png', + }), +})) + +// Mock CardIcon component +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src, size }: { src: string, size: string }) => ( +
+ ), +})) + +// Mock DownloadingIcon +vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({ + default: () =>
, +})) + +// Mock ProgressCircle +vi.mock('@/app/components/base/progress-bar/progress-circle', () => ({ + default: ({ percentage }: { percentage: number }) => ( +
+ ), +})) + +// Mock Tooltip - to avoid nested portal structure +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// Mock portal component with shared state for open/close behavior +let mockPortalOpenState = false + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { + mockPortalOpenState = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + if (!mockPortalOpenState) + return null + return
{children}
+ }, +})) + +// Mock plugin task hooks +const mockHandleRefetch = vi.fn() +const mockMutateAsync = vi.fn() + +let mockPluginTasks: Array<{ + id: string + plugins: Array> +}> = [] + +vi.mock('@/service/use-plugins', () => ({ + usePluginTaskList: () => ({ + pluginTasks: mockPluginTasks, + handleRefetch: mockHandleRefetch, + }), + useMutationClearTaskPlugin: () => ({ + mutateAsync: mockMutateAsync, + }), +})) + +// ============================================================================ +// Test Data Factories +// ============================================================================ + +const createPluginStatus = (overrides: Partial = {}): PluginStatus => ({ + plugin_unique_identifier: `plugin-${Math.random().toString(36).slice(2, 9)}`, + plugin_id: 'plugin-id-1', + status: TaskStatus.running, + message: '', + icon: 'icon.png', + labels: { + 'en-US': 'Test Plugin', + 'zh-Hans': '测试插件', + } as Record, + taskId: 'task-1', + ...overrides, +}) + +const createRunningPlugin = (overrides: Partial = {}): PluginStatus => + createPluginStatus({ status: TaskStatus.running, message: '', ...overrides }) + +const createSuccessPlugin = (overrides: Partial = {}): PluginStatus => + createPluginStatus({ status: TaskStatus.success, message: 'Installed successfully', ...overrides }) + +const createErrorPlugin = (overrides: Partial = {}): PluginStatus => + createPluginStatus({ status: TaskStatus.failed, message: 'Installation failed', ...overrides }) + +const createPluginTask = ( + id: string, + plugins: Array>, +) => ({ + id, + plugins, +}) + +// ============================================================================ +// PluginTasks Main Component Tests +// ============================================================================ + +describe('PluginTasks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + mockPluginTasks = [] + mockMutateAsync.mockResolvedValue({}) + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - verify component renders correctly in different states + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should return null when there are no plugin tasks', () => { + // Arrange + mockPluginTasks = [] + + // Act + const { container } = render() + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render trigger button when there are running plugins', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() + }) + + it('should render trigger button when there are success plugins', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should render trigger button when there are error plugins', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should render trigger button with mixed plugin states', () => { + // Arrange + mockPluginTasks = [ + createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'plugin-1' }), + createSuccessPlugin({ plugin_unique_identifier: 'plugin-2' }), + createErrorPlugin({ plugin_unique_identifier: 'plugin-3' }), + ]), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // User Interactions - test click handlers and panel behavior + // -------------------------------------------------------------------------- + describe('User Interactions', () => { + it('should open panel when clicking trigger with running plugins', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should open panel when clicking trigger with success plugins', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should open panel when clicking trigger with error plugins', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should toggle panel on repeated clicks', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] + render() + + // Act - first click opens + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Act - second click closes + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should show running plugins section when panel is open', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ labels: { 'en-US': 'Running Plugin 1' } as Record }), + ])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByText('plugin.task.installing')).toBeInTheDocument() + expect(screen.getByText('Running Plugin 1')).toBeInTheDocument() + }) + + it('should show success plugins section when panel is open', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ labels: { 'en-US': 'Success Plugin 1' } as Record }), + ])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - text may be split across elements, use container query + expect(screen.getByText(/plugin\.task\.installed/)).toBeInTheDocument() + expect(screen.getByText('Success Plugin 1')).toBeInTheDocument() + }) + + it('should show error plugins section when panel is open', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [ + createErrorPlugin({ + labels: { 'en-US': 'Error Plugin 1' } as Record, + message: 'Failed to install', + }), + ])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByText(/plugin.task.installError/)).toBeInTheDocument() + expect(screen.getByText('Error Plugin 1')).toBeInTheDocument() + expect(screen.getByText('Failed to install')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Clear Actions - test clearing plugins + // -------------------------------------------------------------------------- + describe('Clear Actions', () => { + it('should call clear handler when clicking clear all on success section', async () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + ])] + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act + const clearAllButtons = screen.getAllByText('plugin.task.clearAll') + fireEvent.click(clearAllButtons[0]) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled() + }) + }) + + it('should call clear handler when clicking clear all on error section', async () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [ + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + ])] + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act + const clearAllButton = screen.getByText('plugin.task.clearAll') + fireEvent.click(clearAllButton) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled() + }) + }) + + it('should call clear single when clicking clear on individual error plugin', async () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [ + createErrorPlugin({ plugin_unique_identifier: 'error-1', taskId: 'task-1' }), + ])] + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act + const clearButton = screen.getByText('common.operation.clear') + fireEvent.click(clearButton) + + // Assert + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + taskId: 'task-1', + pluginId: 'error-1', + }) + }) + }) + }) + + // -------------------------------------------------------------------------- + // Multiple Tasks - test handling of plugins from multiple tasks + // -------------------------------------------------------------------------- + describe('Multiple Tasks', () => { + it('should aggregate plugins from multiple tasks', () => { + // Arrange + mockPluginTasks = [ + createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'plugin-1', labels: { 'en-US': 'Plugin 1' } as Record }), + ]), + createPluginTask('task-2', [ + createSuccessPlugin({ plugin_unique_identifier: 'plugin-2', labels: { 'en-US': 'Plugin 2' } as Record }), + ]), + ] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByText('Plugin 1')).toBeInTheDocument() + expect(screen.getByText('Plugin 2')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// PluginTaskTrigger Component Tests +// ============================================================================ + +describe('PluginTaskTrigger', () => { + const defaultProps = { + tip: 'Test tip', + isInstalling: false, + isInstallingWithSuccess: false, + isInstallingWithError: false, + isSuccess: false, + isFailed: false, + successPluginsLength: 0, + runningPluginsLength: 0, + errorPluginsLength: 0, + totalPluginsLength: 1, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // Rendering Tests - verify icons and indicators render correctly + // -------------------------------------------------------------------------- + describe('Rendering', () => { + it('should render with default props', () => { + // Arrange & Act + render() + + // Assert + expect(document.getElementById('plugin-task-trigger')).toBeInTheDocument() + }) + + it('should render downloading icon when isInstalling is true', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() + }) + + it('should render downloading icon when isInstallingWithError is true', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() + }) + + it('should render install icon when not installing', () => { + // Arrange & Act + const { container } = render() + + // Assert - RiInstallLine should be rendered (not downloading icon) + expect(screen.queryByTestId('downloading-icon')).not.toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render progress circle when isInstalling', () => { + // Arrange & Act + render( + , + ) + + // Assert + const progressCircle = screen.getByTestId('progress-circle') + expect(progressCircle).toBeInTheDocument() + expect(progressCircle).toHaveAttribute('data-percentage', '40') + }) + + it('should render progress circle when isInstallingWithSuccess', () => { + // Arrange & Act + render( + , + ) + + // Assert + const progressCircle = screen.getByTestId('progress-circle') + expect(progressCircle).toHaveAttribute('data-percentage', '30') + }) + + it('should render progress circle when isInstallingWithError', () => { + // Arrange & Act + render( + , + ) + + // Assert + const progressCircle = screen.getByTestId('progress-circle') + expect(progressCircle).toHaveAttribute('data-percentage', '40') + }) + + it('should render success indicator when isSuccess', () => { + // Arrange & Act + const { container } = render() + + // Assert - Should have success check icon + expect(container.querySelector('.text-text-success')).toBeInTheDocument() + }) + + it('should render success indicator when all completed with success only', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.querySelector('.text-text-success')).toBeInTheDocument() + }) + + it('should render error indicator when isFailed', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-destructive')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // CSS Class Tests - verify correct styling based on state + // -------------------------------------------------------------------------- + describe('CSS Classes', () => { + it('should apply error styles when hasError (isFailed)', () => { + // Arrange & Act + render() + + // Assert + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('bg-state-destructive-hover') + }) + + it('should apply error styles when hasError (isInstallingWithError)', () => { + // Arrange & Act + render() + + // Assert + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('bg-state-destructive-hover') + }) + + it('should apply cursor-pointer when isInstalling', () => { + // Arrange & Act + render() + + // Assert + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('cursor-pointer') + }) + + it('should apply cursor-pointer when isSuccess', () => { + // Arrange & Act + render() + + // Assert + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('cursor-pointer') + }) + }) +}) + +// ============================================================================ +// Plugin List Section Components Tests +// ============================================================================ + +describe('Plugin List Sections', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // -------------------------------------------------------------------------- + // RunningPluginsSection Tests + // -------------------------------------------------------------------------- + describe('RunningPluginsSection', () => { + it('should return null when plugins array is empty', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render running plugins with loader icon', () => { + // Arrange + const plugins = [ + createRunningPlugin({ + plugin_unique_identifier: 'running-1', + labels: { 'en-US': 'Installing Plugin' } as Record, + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Installing Plugin')).toBeInTheDocument() + expect(screen.getAllByText('plugin.task.installing').length).toBeGreaterThan(0) + }) + + it('should render multiple running plugins', () => { + // Arrange + const plugins = [ + createRunningPlugin({ plugin_unique_identifier: 'running-1', labels: { 'en-US': 'Plugin A' } as Record }), + createRunningPlugin({ plugin_unique_identifier: 'running-2', labels: { 'en-US': 'Plugin B' } as Record }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Plugin A')).toBeInTheDocument() + expect(screen.getByText('Plugin B')).toBeInTheDocument() + }) + + it('should display correct count in header', () => { + // Arrange + const plugins = [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + createRunningPlugin({ plugin_unique_identifier: 'running-2' }), + createRunningPlugin({ plugin_unique_identifier: 'running-3' }), + ] + + // Act + const { container } = render() + + // Assert - count is in header text content + const header = container.querySelector('.system-sm-semibold-uppercase') + expect(header?.textContent).toContain('(') + expect(header?.textContent).toContain('3') + }) + }) + + // -------------------------------------------------------------------------- + // SuccessPluginsSection Tests + // -------------------------------------------------------------------------- + describe('SuccessPluginsSection', () => { + const mockOnClearAll = vi.fn() + + beforeEach(() => { + mockOnClearAll.mockClear() + }) + + it('should return null when plugins array is empty', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render success plugins with check icon', () => { + // Arrange + const plugins = [ + createSuccessPlugin({ + plugin_unique_identifier: 'success-1', + labels: { 'en-US': 'Installed Plugin' } as Record, + message: 'Successfully installed', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Installed Plugin')).toBeInTheDocument() + expect(screen.getByText('Successfully installed')).toBeInTheDocument() + }) + + it('should use default message when plugin message is empty', () => { + // Arrange + const plugins = [ + createSuccessPlugin({ + plugin_unique_identifier: 'success-1', + labels: { 'en-US': 'Plugin' } as Record, + message: '', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('plugin.task.installed')).toBeInTheDocument() + }) + + it('should render clear all button', () => { + // Arrange + const plugins = [createSuccessPlugin()] + + // Act + render() + + // Assert + expect(screen.getByText('plugin.task.clearAll')).toBeInTheDocument() + }) + + it('should call onClearAll when clear all button is clicked', () => { + // Arrange + const plugins = [createSuccessPlugin()] + render() + + // Act + fireEvent.click(screen.getByText('plugin.task.clearAll')) + + // Assert + expect(mockOnClearAll).toHaveBeenCalledTimes(1) + }) + + it('should render multiple success plugins', () => { + // Arrange + const plugins = [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1', labels: { 'en-US': 'Plugin X' } as Record }), + createSuccessPlugin({ plugin_unique_identifier: 'success-2', labels: { 'en-US': 'Plugin Y' } as Record }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Plugin X')).toBeInTheDocument() + expect(screen.getByText('Plugin Y')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // ErrorPluginsSection Tests + // -------------------------------------------------------------------------- + describe('ErrorPluginsSection', () => { + const mockOnClearAll = vi.fn() + const mockOnClearSingle = vi.fn() + + beforeEach(() => { + mockOnClearAll.mockClear() + mockOnClearSingle.mockClear() + }) + + it('should return null when plugins array is empty', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toBeNull() + }) + + it('should render error plugins with error icon', () => { + // Arrange + const plugins = [ + createErrorPlugin({ + plugin_unique_identifier: 'error-1', + labels: { 'en-US': 'Failed Plugin' } as Record, + message: 'Network error', + }), + ] + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Failed Plugin')).toBeInTheDocument() + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + + it('should render clear all button and call onClearAll when clicked', () => { + // Arrange + const plugins = [createErrorPlugin()] + render( + , + ) + + // Act + fireEvent.click(screen.getByText('plugin.task.clearAll')) + + // Assert + expect(mockOnClearAll).toHaveBeenCalledTimes(1) + }) + + it('should render individual clear button for each error plugin', () => { + // Arrange + const plugins = [ + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-2' }), + ] + render( + , + ) + + // Assert + const clearButtons = screen.getAllByText('common.operation.clear') + expect(clearButtons).toHaveLength(2) + }) + + it('should call onClearSingle with correct params when individual clear is clicked', () => { + // Arrange + const plugins = [ + createErrorPlugin({ + plugin_unique_identifier: 'error-plugin-123', + taskId: 'task-456', + }), + ] + render( + , + ) + + // Act + fireEvent.click(screen.getByText('common.operation.clear')) + + // Assert + expect(mockOnClearSingle).toHaveBeenCalledWith('task-456', 'error-plugin-123') + }) + + it('should display error count in header', () => { + // Arrange + const plugins = [ + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-2' }), + ] + + // Act + render( + , + ) + + // Assert + expect(screen.getByText(/plugin.task.installError/)).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Edge Cases and Error Handling +// ============================================================================ + +describe('Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + mockPluginTasks = [] + }) + + it('should handle plugin with missing labels gracefully', () => { + // Arrange + const pluginWithMissingLabel = createRunningPlugin({ + labels: {} as Record, + }) + mockPluginTasks = [createPluginTask('task-1', [pluginWithMissingLabel])] + + // Act & Assert - should not throw + expect(() => { + render() + }).not.toThrow() + }) + + it('should handle empty icon URL', () => { + // Arrange + const pluginWithNoIcon = createRunningPlugin({ + icon: '', + }) + mockPluginTasks = [createPluginTask('task-1', [pluginWithNoIcon])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + const cardIcon = screen.getByTestId('card-icon') + expect(cardIcon).toHaveAttribute('data-src', 'default-icon.png') + }) + + it('should handle very long error messages', () => { + // Arrange + const longMessage = 'A'.repeat(500) + const pluginWithLongMessage = createErrorPlugin({ + message: longMessage, + }) + mockPluginTasks = [createPluginTask('task-1', [pluginWithLongMessage])] + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + expect(screen.getByText(longMessage)).toBeInTheDocument() + }) + + it('should attempt to clear plugin even when API fails', async () => { + // Arrange - API will fail but we should still call it + mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act + const clearButton = screen.getByText('common.operation.clear') + fireEvent.click(clearButton) + + // Assert - mutation should be called + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled() + }) + }) + + it('should calculate progress correctly with zero total', () => { + // Arrange & Act + render( + , + ) + + // Assert - should handle division by zero + const progressCircle = screen.getByTestId('progress-circle') + expect(progressCircle).toHaveAttribute('data-percentage', 'NaN') + }) + + it('should handle rapid toggle clicks', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] + render() + const trigger = screen.getByTestId('portal-trigger') + + // Act - rapid clicks + for (let i = 0; i < 5; i++) + fireEvent.click(trigger) + + // Assert - should end in open state (odd number of clicks) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) +}) + +// ============================================================================ +// Tooltip Integration Tests +// ============================================================================ + +describe('Tooltip Tips', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + mockPluginTasks = [] + }) + + it('should show installing tip when only running', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] + + // Act + render() + + // Assert - tip is passed to trigger, we just verify render doesn't break + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should show success tip when all success', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createSuccessPlugin()])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should show error tip when has failures', () => { + // Arrange + mockPluginTasks = [createPluginTask('task-1', [createErrorPlugin()])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should show installingWithSuccess tip when running with success', () => { + // Arrange - running + success = isInstallingWithSuccess + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + ])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should show installingWithError tip when running with errors', () => { + // Arrange - running + error = isInstallingWithError + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + ])] + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) +}) + +// ============================================================================ +// usePluginTaskPanel Hook Branch Coverage Tests +// ============================================================================ + +describe('usePluginTaskPanel Branch Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + mockPluginTasks = [] + mockMutateAsync.mockResolvedValue({}) + }) + + // -------------------------------------------------------------------------- + // closeIfNoRunning branch - when runningPluginsLength > 0 + // -------------------------------------------------------------------------- + describe('closeIfNoRunning branch', () => { + it('should NOT close panel when there are still running plugins after clearing', async () => { + // Arrange - 2 running plugins, 1 success plugin + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + createRunningPlugin({ plugin_unique_identifier: 'running-2' }), + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + ])] + render() + + // Open the panel + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Act - click clear all (clears success plugins) + const clearAllButton = screen.getByText('plugin.task.clearAll') + fireEvent.click(clearAllButton) + + // Assert - panel should still be open because there are running plugins + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled() + }) + // Panel remains open due to running plugins (runningPluginsLength > 0) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should close panel when no running plugins remain after clearing', async () => { + // Arrange - only success plugins (no running) + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + ])] + render() + + // Open the panel + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + // Act - click clear all + const clearAllButton = screen.getByText('plugin.task.clearAll') + fireEvent.click(clearAllButton) + + // Assert - mutation should be called + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled() + }) + }) + }) + + // -------------------------------------------------------------------------- + // handleClearErrors branch + // -------------------------------------------------------------------------- + describe('handleClearErrors', () => { + it('should clear only error plugins when clicking clear all in error section', async () => { + // Arrange - mix of success and error plugins + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-2' }), + ])] + render() + + // Open the panel + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act - click clear all in error section (second clearAll button) + const clearAllButtons = screen.getAllByText('plugin.task.clearAll') + // Error section clear button + fireEvent.click(clearAllButtons[1]) + + // Assert - should clear error plugins + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(2) // 2 error plugins + }) + }) + }) + + // -------------------------------------------------------------------------- + // handleClearAll clears both success and error plugins + // -------------------------------------------------------------------------- + describe('handleClearAll', () => { + it('should clear both success and error plugins', async () => { + // Arrange - mix of success and error plugins + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + createSuccessPlugin({ plugin_unique_identifier: 'success-2' }), + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + ])] + render() + + // Open the panel + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act - click clear all in success section (first clearAll button) + const clearAllButtons = screen.getAllByText('plugin.task.clearAll') + fireEvent.click(clearAllButtons[0]) + + // Assert - should clear all completed plugins (3 total: 2 success + 1 error) + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(3) + }) + }) + }) + + // -------------------------------------------------------------------------- + // canOpenPanel false branch - when no valid state to open panel + // -------------------------------------------------------------------------- + describe('canOpenPanel false branch', () => { + it('should not open panel when clicking trigger in invalid state', () => { + // This branch is difficult to test because the hook logic always returns + // a valid state when there are plugins. The canOpenPanel is computed from + // isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess + // which covers all possible states when totalPluginsLength > 0 + + // The only way canOpenPanel can be false is if all status flags are false, + // which theoretically shouldn't happen with the current hook logic + // This test documents the behavior + mockPluginTasks = [createPluginTask('task-1', [createRunningPlugin()])] + render() + + // Initial state - panel should be closed + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + + // Click trigger - should open because isInstalling is true + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + // -------------------------------------------------------------------------- + // Tip message variations - all branches + // -------------------------------------------------------------------------- + describe('tip message branches', () => { + it('should compute isInstallingWithSuccess tip correctly', () => { + // Arrange - running > 0, success > 0, error = 0 triggers isInstallingWithSuccess + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + ])] + + // Act + render() + + // Assert - component renders with isInstallingWithSuccess state + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByTestId('progress-circle')).toBeInTheDocument() + }) + + it('should compute isInstallingWithError tip correctly', () => { + // Arrange - running > 0, error > 0 triggers isInstallingWithError + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + ])] + + // Act + render() + + // Assert - component renders with error styling + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('bg-state-destructive-hover') + }) + + it('should compute isInstalling tip correctly (only running)', () => { + // Arrange - only running plugins + mockPluginTasks = [createPluginTask('task-1', [ + createRunningPlugin({ plugin_unique_identifier: 'running-1' }), + ])] + + // Act + render() + + // Assert + expect(screen.getByTestId('downloading-icon')).toBeInTheDocument() + }) + + it('should compute isFailed tip correctly (errors without running)', () => { + // Arrange - errors only, no running + mockPluginTasks = [createPluginTask('task-1', [ + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + ])] + + // Act + render() + + // Assert - should show error indicator + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('bg-state-destructive-hover') + }) + + it('should compute isSuccess tip correctly (all success)', () => { + // Arrange - all success plugins + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + createSuccessPlugin({ plugin_unique_identifier: 'success-2' }), + ])] + + // Act + const { container } = render() + + // Assert - should show success indicator + expect(container.querySelector('.text-text-success')).toBeInTheDocument() + }) + + it('should compute isFailed with mixed success and error (but no running)', () => { + // Arrange - success + error, no running = isFailed (because errorPluginsLength > 0) + mockPluginTasks = [createPluginTask('task-1', [ + createSuccessPlugin({ plugin_unique_identifier: 'success-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-1' }), + ])] + + // Act + render() + + // Assert - should show error styling because isFailed is true + const trigger = document.getElementById('plugin-task-trigger') + expect(trigger?.className).toContain('bg-state-destructive-hover') + }) + }) + + // -------------------------------------------------------------------------- + // clearPlugins iteration - clearing multiple plugins + // -------------------------------------------------------------------------- + describe('clearPlugins iteration', () => { + it('should clear plugins one by one in order', async () => { + // Arrange - multiple error plugins + mockPluginTasks = [createPluginTask('task-1', [ + createErrorPlugin({ plugin_unique_identifier: 'error-1', taskId: 'task-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-2', taskId: 'task-1' }), + createErrorPlugin({ plugin_unique_identifier: 'error-3', taskId: 'task-1' }), + ])] + render() + + // Open panel + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Act - click clear all in error section + const clearAllButton = screen.getByText('plugin.task.clearAll') + fireEvent.click(clearAllButton) + + // Assert - should call mutateAsync for each plugin + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledTimes(3) + }) + + // Verify each plugin was cleared + expect(mockMutateAsync).toHaveBeenNthCalledWith(1, { + taskId: 'task-1', + pluginId: 'error-1', + }) + expect(mockMutateAsync).toHaveBeenNthCalledWith(2, { + taskId: 'task-1', + pluginId: 'error-2', + }) + expect(mockMutateAsync).toHaveBeenNthCalledWith(3, { + taskId: 'task-1', + pluginId: 'error-3', + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx index b4c1692533..dc7bee7770 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx @@ -1,34 +1,27 @@ -import { - RiCheckboxCircleFill, - RiErrorWarningFill, - RiInstallLine, - RiLoaderLine, -} from '@remixicon/react' -import { - useCallback, - useMemo, - useState, -} from 'react' -import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' -import Tooltip from '@/app/components/base/tooltip' -import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon' -import CardIcon from '@/app/components/plugins/card/base/card-icon' -import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' -import { useGetLanguage } from '@/context/i18n' -import { cn } from '@/utils/classnames' -import { usePluginTaskStatus } from './hooks' +import { + ErrorPluginsSection, + RunningPluginsSection, + SuccessPluginsSection, +} from './plugin-task-list' +import PluginTaskTrigger from './plugin-task-trigger' +import { usePluginTaskPanel } from './use-plugin-task-panel' const PluginTasks = () => { - const { t } = useTranslation() - const language = useGetLanguage() - const [open, setOpen] = useState(false) + const { + open, + setOpen, + tip, + taskStatus, + handleClearAll, + handleClearErrors, + handleClearSingle, + } = usePluginTaskPanel() + const { errorPlugins, successPlugins, @@ -42,68 +35,15 @@ const PluginTasks = () => { isInstallingWithError, isSuccess, isFailed, - handleClearErrorPlugin, - } = usePluginTaskStatus() - const { getIconUrl } = useGetIcon() - - const handleClearAllWithModal = useCallback(async () => { - // Clear all completed plugins (success and error) but keep running ones - const completedPlugins = [...successPlugins, ...errorPlugins] - - // Clear all completed plugins individually - for (const plugin of completedPlugins) - await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) - - // Only close modal if no plugins are still installing - if (runningPluginsLength === 0) - setOpen(false) - }, [successPlugins, errorPlugins, handleClearErrorPlugin, runningPluginsLength]) - - const handleClearErrorsWithModal = useCallback(async () => { - // Clear only error plugins, not all plugins - for (const plugin of errorPlugins) - await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) - // Only close modal if no plugins are still installing - if (runningPluginsLength === 0) - setOpen(false) - }, [errorPlugins, handleClearErrorPlugin, runningPluginsLength]) - - const handleClearSingleWithModal = useCallback(async (taskId: string, pluginId: string) => { - await handleClearErrorPlugin(taskId, pluginId) - // Only close modal if no plugins are still installing - if (runningPluginsLength === 0) - setOpen(false) - }, [handleClearErrorPlugin, runningPluginsLength]) - - const tip = useMemo(() => { - if (isInstallingWithError) - return t('plugin.task.installingWithError', { installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength }) - if (isInstallingWithSuccess) - return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength }) - if (isInstalling) - return t('plugin.task.installing') - if (isFailed) - return t('plugin.task.installedError', { errorLength: errorPluginsLength }) - if (isSuccess) - return t('plugin.task.installSuccess', { successLength: successPluginsLength }) - return t('plugin.task.installed') - }, [ - errorPluginsLength, - isFailed, - isInstalling, - isInstallingWithError, - isInstallingWithSuccess, - isSuccess, - runningPluginsLength, - successPluginsLength, - t, - ]) + } = taskStatus // Show icon if there are any plugin tasks (completed, running, or failed) // Only hide when there are absolutely no plugin tasks if (totalPluginsLength === 0) return null + const canOpenPanel = isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess + return (
{ > { - if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess) - setOpen(v => !v) + if (canOpenPanel) + setOpen(!open) }} > - -
- { - (isInstalling || isInstallingWithError) && ( - - ) - } - { - !(isInstalling || isInstallingWithError) && ( - - ) - } -
- { - (isInstalling || isInstallingWithSuccess) && ( - - ) - } - { - isInstallingWithError && ( - - ) - } - { - (isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0)) && ( - - ) - } - { - isFailed && ( - - ) - } -
-
-
+
- {/* Running Plugins */} - {runningPlugins.length > 0 && ( - <> -
- {t('plugin.task.installing')} - {' '} - ( - {runningPlugins.length} - ) -
-
- {runningPlugins.map(runningPlugin => ( -
-
- - -
-
-
- {runningPlugin.labels[language]} -
-
- {t('plugin.task.installing')} -
-
-
- ))} -
- - )} - - {/* Success Plugins */} - {successPlugins.length > 0 && ( - <> -
- {t('plugin.task.installed')} - {' '} - ( - {successPlugins.length} - ) - -
-
- {successPlugins.map(successPlugin => ( -
-
- - -
-
-
- {successPlugin.labels[language]} -
-
- {successPlugin.message || t('plugin.task.installed')} -
-
-
- ))} -
- - )} - - {/* Error Plugins */} - {errorPlugins.length > 0 && ( - <> -
- {t('plugin.task.installError', { errorLength: errorPlugins.length })} - -
-
- {errorPlugins.map(errorPlugin => ( -
-
- - -
-
-
- {errorPlugin.labels[language]} -
-
- {errorPlugin.message} -
-
- -
- ))} -
- - )} + + +
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx new file mode 100644 index 0000000000..0aa04a707c --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-list.tsx @@ -0,0 +1,202 @@ +import type { ReactNode } from 'react' +import type { PluginStatus } from '@/app/components/plugins/types' +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 useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' +import { useGetLanguage } from '@/context/i18n' + +// Plugin item base component +type PluginTaskItemProps = { + plugin: PluginStatus + statusIcon: ReactNode + statusText: string + statusClassName?: string + action?: ReactNode +} + +const PluginTaskItem = ({ + plugin, + statusIcon, + statusText, + statusClassName = 'text-text-tertiary', + action, +}: PluginTaskItemProps) => { + const language = useGetLanguage() + const { getIconUrl } = useGetIcon() + + return ( +
+
+ {statusIcon} + +
+
+
+ {plugin.labels[language]} +
+
+ {statusText} +
+
+ {action} +
+ ) +} + +// Section header component +type SectionHeaderProps = { + title: string + count: number + action?: ReactNode +} + +const SectionHeader = ({ title, count, action }: SectionHeaderProps) => ( +
+ {title} + {' '} + ( + {count} + ) + {action} +
+) + +// Running plugins section +type RunningPluginsSectionProps = { + plugins: PluginStatus[] +} + +export const RunningPluginsSection = ({ plugins }: RunningPluginsSectionProps) => { + const { t } = useTranslation() + + if (plugins.length === 0) + return null + + return ( + <> + +
+ {plugins.map(plugin => ( + + } + statusText={t('plugin.task.installing')} + /> + ))} +
+ + ) +} + +// Success plugins section +type SuccessPluginsSectionProps = { + plugins: PluginStatus[] + onClearAll: () => void +} + +export const SuccessPluginsSection = ({ plugins, onClearAll }: SuccessPluginsSectionProps) => { + const { t } = useTranslation() + + if (plugins.length === 0) + return null + + return ( + <> + + {t('plugin.task.clearAll')} + + )} + /> +
+ {plugins.map(plugin => ( + + } + statusText={plugin.message || t('plugin.task.installed')} + statusClassName="text-text-success" + /> + ))} +
+ + ) +} + +// Error plugins section +type ErrorPluginsSectionProps = { + plugins: PluginStatus[] + onClearAll: () => void + onClearSingle: (taskId: string, pluginId: string) => void +} + +export const ErrorPluginsSection = ({ plugins, onClearAll, onClearSingle }: ErrorPluginsSectionProps) => { + const { t } = useTranslation() + + if (plugins.length === 0) + return null + + return ( + <> + + {t('plugin.task.clearAll')} + + )} + /> +
+ {plugins.map(plugin => ( + + } + statusText={plugin.message} + statusClassName="break-all text-text-destructive" + action={( + + )} + /> + ))} +
+ + ) +} diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx new file mode 100644 index 0000000000..d860854869 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/plugin-task-trigger.tsx @@ -0,0 +1,94 @@ +import { + RiCheckboxCircleFill, + RiErrorWarningFill, + RiInstallLine, +} from '@remixicon/react' +import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' +import Tooltip from '@/app/components/base/tooltip' +import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon' +import { cn } from '@/utils/classnames' + +type PluginTaskTriggerProps = { + tip: string + isInstalling: boolean + isInstallingWithSuccess: boolean + isInstallingWithError: boolean + isSuccess: boolean + isFailed: boolean + successPluginsLength: number + runningPluginsLength: number + errorPluginsLength: number + totalPluginsLength: number +} + +const PluginTaskTrigger = ({ + tip, + isInstalling, + isInstallingWithSuccess, + isInstallingWithError, + isSuccess, + isFailed, + successPluginsLength, + runningPluginsLength, + errorPluginsLength, + totalPluginsLength, +}: PluginTaskTriggerProps) => { + const showDownloadingIcon = isInstalling || isInstallingWithError + const hasError = isInstallingWithError || isFailed + const showSuccessIndicator = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0 && errorPluginsLength === 0) + + return ( + +
+ {/* Main Icon */} + {showDownloadingIcon + ? + : ( + + )} + + {/* Status Indicator */} +
+ {(isInstalling || isInstallingWithSuccess) && ( + + )} + {isInstallingWithError && ( + + )} + {showSuccessIndicator && ( + + )} + {isFailed && ( + + )} +
+
+
+ ) +} + +export default PluginTaskTrigger diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts b/web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts new file mode 100644 index 0000000000..1b35cd9903 --- /dev/null +++ b/web/app/components/plugins/plugin-page/plugin-tasks/use-plugin-task-panel.ts @@ -0,0 +1,94 @@ +import type { PluginStatus } from '@/app/components/plugins/types' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginTaskStatus } from './hooks' + +type PluginTaskPanelState = { + open: boolean + setOpen: (open: boolean) => void + tip: string + taskStatus: ReturnType + handleClearAll: () => Promise + handleClearErrors: () => Promise + handleClearSingle: (taskId: string, pluginId: string) => Promise +} + +export const usePluginTaskPanel = (): PluginTaskPanelState => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const taskStatus = usePluginTaskStatus() + + const { + errorPlugins, + successPlugins, + runningPluginsLength, + successPluginsLength, + errorPluginsLength, + isInstalling, + isInstallingWithSuccess, + isInstallingWithError, + isSuccess, + isFailed, + handleClearErrorPlugin, + } = taskStatus + + const closeIfNoRunning = useCallback(() => { + if (runningPluginsLength === 0) + setOpen(false) + }, [runningPluginsLength]) + + const clearPlugins = useCallback(async (plugins: PluginStatus[]) => { + for (const plugin of plugins) + await handleClearErrorPlugin(plugin.taskId, plugin.plugin_unique_identifier) + }, [handleClearErrorPlugin]) + + const handleClearAll = useCallback(async () => { + // Clear all completed plugins (success and error) but keep running ones + await clearPlugins([...successPlugins, ...errorPlugins]) + closeIfNoRunning() + }, [successPlugins, errorPlugins, clearPlugins, closeIfNoRunning]) + + const handleClearErrors = useCallback(async () => { + await clearPlugins(errorPlugins) + closeIfNoRunning() + }, [errorPlugins, clearPlugins, closeIfNoRunning]) + + const handleClearSingle = useCallback(async (taskId: string, pluginId: string) => { + await handleClearErrorPlugin(taskId, pluginId) + closeIfNoRunning() + }, [handleClearErrorPlugin, closeIfNoRunning]) + + const tip = useMemo(() => { + if (isInstallingWithError) + return t('plugin.task.installingWithError', { installingLength: runningPluginsLength, successLength: successPluginsLength, errorLength: errorPluginsLength }) + if (isInstallingWithSuccess) + return t('plugin.task.installingWithSuccess', { installingLength: runningPluginsLength, successLength: successPluginsLength }) + if (isInstalling) + return t('plugin.task.installing') + if (isFailed) + return t('plugin.task.installedError', { errorLength: errorPluginsLength }) + if (isSuccess) + return t('plugin.task.installSuccess', { successLength: successPluginsLength }) + return t('plugin.task.installed') + }, [ + errorPluginsLength, + isFailed, + isInstalling, + isInstallingWithError, + isInstallingWithSuccess, + isSuccess, + runningPluginsLength, + successPluginsLength, + t, + ]) + + return { + open, + setOpen, + tip, + taskStatus, + handleClearAll, + handleClearErrors, + handleClearSingle, + } +}