From e652abbfc721fefabfac4234c312ba08a1442048 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 30 Mar 2026 15:56:08 +0800 Subject: [PATCH] refactor(app-info): remove unused app-info component and add tests for app-info actions and dropdown menu --- .../app-sidebar/__tests__/app-info.spec.tsx | 31 ++ web/app/components/app-sidebar/app-info.tsx | 511 ------------------ .../__tests__/use-app-info-actions.spec.ts | 105 ++-- .../dataset-info/__tests__/dropdown.spec.tsx | 318 +++++++++++ .../dataset-info/__tests__/menu-item.spec.tsx | 26 + .../dataset-info/__tests__/menu.spec.tsx | 145 +++++ web/app/components/tools/types.ts | 1 + .../workflow-tool/__tests__/index.spec.tsx | 229 ++++++++ .../__tests__/use-workflow-tool-form.spec.ts | 112 ++++ .../__tests__/workflow-tool-form.spec.tsx | 153 ++++++ .../components/tools/workflow-tool/index.tsx | 398 ++------------ .../components/tools/workflow-tool/types.ts | 29 + .../workflow-tool/use-workflow-tool-form.ts | 131 +++++ .../workflow-tool/workflow-tool-form.tsx | 270 +++++++++ 14 files changed, 1564 insertions(+), 895 deletions(-) create mode 100644 web/app/components/app-sidebar/__tests__/app-info.spec.tsx delete mode 100644 web/app/components/app-sidebar/app-info.tsx create mode 100644 web/app/components/app-sidebar/dataset-info/__tests__/dropdown.spec.tsx create mode 100644 web/app/components/app-sidebar/dataset-info/__tests__/menu-item.spec.tsx create mode 100644 web/app/components/app-sidebar/dataset-info/__tests__/menu.spec.tsx create mode 100644 web/app/components/tools/workflow-tool/__tests__/index.spec.tsx create mode 100644 web/app/components/tools/workflow-tool/__tests__/use-workflow-tool-form.spec.ts create mode 100644 web/app/components/tools/workflow-tool/__tests__/workflow-tool-form.spec.tsx create mode 100644 web/app/components/tools/workflow-tool/types.ts create mode 100644 web/app/components/tools/workflow-tool/use-workflow-tool-form.ts create mode 100644 web/app/components/tools/workflow-tool/workflow-tool-form.tsx diff --git a/web/app/components/app-sidebar/__tests__/app-info.spec.tsx b/web/app/components/app-sidebar/__tests__/app-info.spec.tsx new file mode 100644 index 0000000000..9cd139ca18 --- /dev/null +++ b/web/app/components/app-sidebar/__tests__/app-info.spec.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import AppInfo from '../app-info' + +vi.mock('../app-info/index', () => ({ + default: ({ + expand, + onlyShowDetail = false, + openState = false, + }: { + expand: boolean + onlyShowDetail?: boolean + openState?: boolean + }) => ( +
+ ), +})) + +describe('app-sidebar/app-info entrypoint', () => { + it('should forward props to the modular app-info implementation', () => { + render() + + expect(screen.getByTestId('app-info-inner')).toHaveAttribute('data-expand', 'true') + expect(screen.getByTestId('app-info-inner')).toHaveAttribute('data-only-show-detail', 'true') + expect(screen.getByTestId('app-info-inner')).toHaveAttribute('data-open-state', 'true') + }) +}) diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx deleted file mode 100644 index 02cf281224..0000000000 --- a/web/app/components/app-sidebar/app-info.tsx +++ /dev/null @@ -1,511 +0,0 @@ -import type { Operation } from './app-info/app-operations' -import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' -import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' -import type { EnvironmentVariable } from '@/app/components/workflow/types' -import { - RiDeleteBinLine, - RiEditLine, - RiEqualizer2Line, - RiExchange2Line, - RiFileCopy2Line, - RiFileDownloadLine, - RiFileUploadLine, -} from '@remixicon/react' -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view' -import { useStore as useAppStore } from '@/app/components/app/store' -import Button from '@/app/components/base/button' - -import ContentDialog from '@/app/components/base/content-dialog' -import { toast } from '@/app/components/base/ui/toast' -import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' -import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' -import { NEED_REFRESH_APP_LIST_KEY } from '@/config' -import { useAppContext } from '@/context/app-context' -import { useProviderContext } from '@/context/provider-context' -import dynamic from '@/next/dynamic' -import { useRouter } from '@/next/navigation' -import { copyApp, deleteApp, exportAppBundle, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps' -import { useInvalidateAppList } from '@/service/use-apps' -import { fetchWorkflowDraft } from '@/service/workflow' -import { AppModeEnum } from '@/types/app' -import { getRedirection } from '@/utils/app-redirection' -import { cn } from '@/utils/classnames' -import { downloadBlob } from '@/utils/download' -import AppIcon from '../base/app-icon' -import AppOperations from './app-info/app-operations' - -const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { - ssr: false, -}) -const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { - ssr: false, -}) -const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), { - ssr: false, -}) -const Confirm = dynamic(() => import('@/app/components/base/confirm'), { - ssr: false, -}) -const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), { - ssr: false, -}) -const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { - ssr: false, -}) - -export type IAppInfoProps = { - expand: boolean - onlyShowDetail?: boolean - openState?: boolean - onDetailExpand?: (expand: boolean) => void -} - -const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => { - const { t } = useTranslation() - - const { replace } = useRouter() - const { onPlanInfoChanged } = useProviderContext() - const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) - const invalidateAppList = useInvalidateAppList() - const [open, setOpen] = useState(openState) - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showImportDSLModal, setShowImportDSLModal] = useState(false) - const [secretEnvList, setSecretEnvList] = useState([]) - const [showExportWarning, setShowExportWarning] = useState(false) - const [exportSandboxed, setExportSandboxed] = useState(false) - - const emitAppMetaUpdate = useCallback(() => { - if (!appDetail?.id) - return - const socket = webSocketClient.getSocket(appDetail.id) - if (socket) { - socket.emit('collaboration_event', { - type: 'app_meta_update', - data: { timestamp: Date.now() }, - timestamp: Date.now(), - }) - } - }, [appDetail]) - - const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) => { - if (!appDetail) - return - try { - const app = await updateAppInfo({ - appID: appDetail.id, - name, - icon_type, - icon, - icon_background, - description, - use_icon_as_answer_icon, - max_active_requests, - }) - setShowEditModal(false) - toast.success(t('editDone', { ns: 'app' })) - setAppDetail(app) - emitAppMetaUpdate() - } - catch { - toast.error(t('editFailed', { ns: 'app' })) - } - }, [appDetail, setAppDetail, t, emitAppMetaUpdate]) - - const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => { - if (!appDetail) - return - try { - const newApp = await copyApp({ - appID: appDetail.id, - name, - icon_type, - icon, - icon_background, - mode: appDetail.mode, - }) - setShowDuplicateModal(false) - toast.success(t('newApp.appCreated', { ns: 'app' })) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') - onPlanInfoChanged() - getRedirection(true, newApp, replace) - } - catch { - toast.error(t('newApp.appCreateFailed', { ns: 'app' })) - } - } - - const onExport = async (include = false, sandboxed = false) => { - if (!appDetail) - return - try { - if (sandboxed) { - await exportAppBundle({ - appID: appDetail.id, - include, - }) - return - } - const { data } = await exportAppConfig({ - appID: appDetail.id, - include, - }) - const file = new Blob([data], { type: 'application/yaml' }) - downloadBlob({ data: file, fileName: `${appDetail.name}.yaml` }) - } - catch { - toast.error(t('exportFailed', { ns: 'app' })) - } - } - - const exportCheck = async () => { - if (!appDetail) - return - if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) { - onExport(false, false) - return - } - - setShowExportWarning(true) - } - - const handleConfirmExport = async () => { - if (!appDetail) - return - setShowExportWarning(false) - try { - const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`) - const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret') - const sandboxed = workflowDraft.features?.sandbox?.enabled === true - if (list.length === 0) { - onExport(false, sandboxed) - return - } - setSecretEnvList(list) - setExportSandboxed(sandboxed) - } - catch { - toast.error(t('exportFailed', { ns: 'app' })) - } - } - - const onConfirmDelete = useCallback(async () => { - if (!appDetail) - return - try { - await deleteApp(appDetail.id) - toast.success(t('appDeleted', { ns: 'app' })) - invalidateAppList() - onPlanInfoChanged() - setAppDetail() - replace('/apps') - } - catch (e: unknown) { - const suffix = typeof e === 'object' && e !== null && 'message' in e - ? `: ${String((e as { message: unknown }).message)}` - : '' - toast.error(`${t('appDeleteFailed', { ns: 'app' })}${suffix}`) - } - setShowConfirmDelete(false) - }, [appDetail, invalidateAppList, onPlanInfoChanged, replace, setAppDetail, t]) - - useEffect(() => { - if (!appDetail?.id) - return - - const unsubscribe = collaborationManager.onAppMetaUpdate(async () => { - try { - const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) - setAppDetail({ ...res }) - } - catch (error) { - console.error('failed to refresh app detail from collaboration update:', error) - } - }) - - return unsubscribe - }, [appDetail?.id, setAppDetail]) - - const { isCurrentWorkspaceEditor } = useAppContext() - - if (!appDetail) - return null - - const primaryOperations = [ - { - id: 'edit', - title: t('editApp', { ns: 'app' }), - icon: , - onClick: () => { - setOpen(false) - onDetailExpand?.(false) - setShowEditModal(true) - }, - }, - { - id: 'duplicate', - title: t('duplicate', { ns: 'app' }), - icon: , - onClick: () => { - setOpen(false) - onDetailExpand?.(false) - setShowDuplicateModal(true) - }, - }, - { - id: 'export', - title: t('export', { ns: 'app' }), - icon: , - onClick: exportCheck, - }, - ] - - const secondaryOperations: Operation[] = [ - // Import DSL (conditional) - ...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) - ? [{ - id: 'import', - title: t('importApp', { ns: 'app' }), - icon: , - onClick: () => { - setOpen(false) - onDetailExpand?.(false) - setShowImportDSLModal(true) - }, - }] - : [], - // Divider - { - id: 'divider-1', - title: '', - icon: <>, - onClick: () => { /* divider has no action */ }, - type: 'divider' as const, - }, - // Delete operation - { - id: 'delete', - title: t('operation.delete', { ns: 'common' }), - icon: , - onClick: () => { - setOpen(false) - onDetailExpand?.(false) - setShowConfirmDelete(true) - }, - }, - ] - - // Keep the switch operation separate as it's not part of the main operations - const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT) - ? { - id: 'switch', - title: t('switch', { ns: 'app' }), - icon: , - onClick: () => { - setOpen(false) - onDetailExpand?.(false) - setShowSwitchModal(true) - }, - } - : null - - return ( -
- {!onlyShowDetail && ( - - )} - { - setOpen(false) - onDetailExpand?.(false) - }} - className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0" - > -
-
- -
-
{appDetail.name}
-
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('types.advanced', { ns: 'app' }) : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('types.agent', { ns: 'app' }) : appDetail.mode === AppModeEnum.CHAT ? t('types.chatbot', { ns: 'app' }) : appDetail.mode === AppModeEnum.COMPLETION ? t('types.completion', { ns: 'app' }) : t('types.workflow', { ns: 'app' })}
-
-
- {/* description */} - {appDetail.description && ( -
{appDetail.description}
- )} - {/* operations */} - -
- - {/* Switch operation (if available) */} - {switchOperation && ( -
- -
- )} -
- {showSwitchModal && ( - setShowSwitchModal(false)} - onSuccess={() => setShowSwitchModal(false)} - /> - )} - {showEditModal && ( - setShowEditModal(false)} - /> - )} - {showDuplicateModal && ( - setShowDuplicateModal(false)} - /> - )} - {showConfirmDelete && ( - setShowConfirmDelete(false)} - /> - )} - {showImportDSLModal && ( - setShowImportDSLModal(false)} - onBackup={exportCheck} - /> - )} - {secretEnvList.length > 0 && ( - onExport(include, exportSandboxed)} - onClose={() => setSecretEnvList([])} - /> - )} - {showExportWarning && ( - setShowExportWarning(false)} - /> - )} -
- ) -} - -export default React.memo(AppInfo) diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 1fe0b6ddb5..d73e3f6062 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -2,26 +2,42 @@ import { act, renderHook } from '@testing-library/react' import { AppModeEnum } from '@/types/app' import { useAppInfoActions } from '../use-app-info-actions' -const mockNotify = vi.fn() -const mockReplace = vi.fn() -const mockOnPlanInfoChanged = vi.fn() -const mockInvalidateAppList = vi.fn() -const mockSetAppDetail = vi.fn() -const mockUpdateAppInfo = vi.fn() -const mockCopyApp = vi.fn() -const mockExportAppConfig = vi.fn() -const mockDeleteApp = vi.fn() -const mockFetchWorkflowDraft = vi.fn() -const mockDownloadBlob = vi.fn() - -let mockAppDetail: Record | undefined = { - id: 'app-1', - name: 'Test App', - mode: AppModeEnum.CHAT, - icon: '๐Ÿค–', - icon_type: 'emoji', - icon_background: '#FFEAD5', -} +const { + mockAppDetail, + mockCopyApp, + mockDeleteApp, + mockDownloadBlob, + mockExportAppConfig, + mockFetchWorkflowDraft, + mockInvalidateAppList, + mockNotify, + mockOnPlanInfoChanged, + mockReplace, + mockSetAppDetail, + mockUpdateAppInfo, +} = vi.hoisted(() => ({ + mockAppDetail: { + current: { + id: 'app-1', + name: 'Test App', + mode: 'chat', + icon: '๐Ÿค–', + icon_type: 'emoji', + icon_background: '#FFEAD5', + } as Record | undefined, + }, + mockCopyApp: vi.fn(), + mockDeleteApp: vi.fn(), + mockDownloadBlob: vi.fn(), + mockExportAppConfig: vi.fn(), + mockFetchWorkflowDraft: vi.fn(), + mockInvalidateAppList: vi.fn(), + mockNotify: vi.fn(), + mockOnPlanInfoChanged: vi.fn(), + mockReplace: vi.fn(), + mockSetAppDetail: vi.fn(), + mockUpdateAppInfo: vi.fn(), +})) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }), @@ -33,7 +49,7 @@ vi.mock('@/context/provider-context', () => ({ vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ - appDetail: mockAppDetail, + appDetail: mockAppDetail.current, setAppDetail: mockSetAppDetail, }), })) @@ -80,7 +96,7 @@ vi.mock('@/config', () => ({ describe('useAppInfoActions', () => { beforeEach(() => { vi.clearAllMocks() - mockAppDetail = { + mockAppDetail.current = { id: 'app-1', name: 'Test App', mode: AppModeEnum.CHAT, @@ -93,7 +109,7 @@ describe('useAppInfoActions', () => { describe('Initial state', () => { it('should return initial state correctly', () => { const { result } = renderHook(() => useAppInfoActions({})) - expect(result.current.appDetail).toEqual(mockAppDetail) + expect(result.current.appDetail).toEqual(mockAppDetail.current) expect(result.current.panelOpen).toBe(false) expect(result.current.activeModal).toBeNull() expect(result.current.secretEnvList).toEqual([]) @@ -161,7 +177,7 @@ describe('useAppInfoActions', () => { describe('onEdit', () => { it('should update app info and close modal on success', async () => { - const updatedApp = { ...mockAppDetail, name: 'Updated' } + const updatedApp = { ...mockAppDetail.current, name: 'Updated' } mockUpdateAppInfo.mockResolvedValue(updatedApp) const { result } = renderHook(() => useAppInfoActions({})) @@ -179,7 +195,7 @@ describe('useAppInfoActions', () => { expect(mockUpdateAppInfo).toHaveBeenCalled() expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp) - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' }) + expect(mockNotify).toHaveBeenCalledWith('app.editDone', { type: 'success' }) }) it('should notify error on edit failure', async () => { @@ -198,11 +214,11 @@ describe('useAppInfoActions', () => { }) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) + expect(mockNotify).toHaveBeenCalledWith('app.editFailed', { type: 'error' }) }) it('should not call updateAppInfo when appDetail is undefined', async () => { - mockAppDetail = undefined + mockAppDetail.current = undefined const { result } = renderHook(() => useAppInfoActions({})) @@ -238,7 +254,7 @@ describe('useAppInfoActions', () => { }) expect(mockCopyApp).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) + expect(mockNotify).toHaveBeenCalledWith('app.newApp.appCreated', { type: 'success' }) expect(mockOnPlanInfoChanged).toHaveBeenCalled() }) @@ -256,13 +272,13 @@ describe('useAppInfoActions', () => { }) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + expect(mockNotify).toHaveBeenCalledWith('app.newApp.appCreateFailed', { type: 'error' }) }) }) describe('onCopy - early return', () => { it('should not call copyApp when appDetail is undefined', async () => { - mockAppDetail = undefined + mockAppDetail.current = undefined const { result } = renderHook(() => useAppInfoActions({})) @@ -302,13 +318,13 @@ describe('useAppInfoActions', () => { await result.current.onExport() }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(mockNotify).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) }) }) describe('onExport - early return', () => { it('should not export when appDetail is undefined', async () => { - mockAppDetail = undefined + mockAppDetail.current = undefined const { result } = renderHook(() => useAppInfoActions({})) @@ -334,7 +350,7 @@ describe('useAppInfoActions', () => { }) it('should open export warning modal for workflow mode', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } + mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.WORKFLOW } const { result } = renderHook(() => useAppInfoActions({})) @@ -346,7 +362,7 @@ describe('useAppInfoActions', () => { }) it('should open export warning modal for advanced_chat mode', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT } + mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.ADVANCED_CHAT } const { result } = renderHook(() => useAppInfoActions({})) @@ -360,7 +376,7 @@ describe('useAppInfoActions', () => { describe('exportCheck - early return', () => { it('should not do anything when appDetail is undefined', async () => { - mockAppDetail = undefined + mockAppDetail.current = undefined const { result } = renderHook(() => useAppInfoActions({})) @@ -374,7 +390,7 @@ describe('useAppInfoActions', () => { describe('handleConfirmExport', () => { it('should export directly when no secret env variables', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } + mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.WORKFLOW } mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [{ value_type: 'string' }], }) @@ -390,7 +406,7 @@ describe('useAppInfoActions', () => { }) it('should set secret env list when secret variables exist', async () => { - mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW } + mockAppDetail.current = { ...mockAppDetail.current, mode: AppModeEnum.WORKFLOW } const secretVars = [{ value_type: 'secret', key: 'API_KEY' }] mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars, @@ -414,13 +430,13 @@ describe('useAppInfoActions', () => { await result.current.handleConfirmExport() }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(mockNotify).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) }) }) describe('handleConfirmExport - early return', () => { it('should not do anything when appDetail is undefined', async () => { - mockAppDetail = undefined + mockAppDetail.current = undefined const { result } = renderHook(() => useAppInfoActions({})) @@ -460,14 +476,14 @@ describe('useAppInfoActions', () => { }) expect(mockDeleteApp).toHaveBeenCalledWith('app-1') - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' }) + expect(mockNotify).toHaveBeenCalledWith('app.appDeleted', { type: 'success' }) expect(mockInvalidateAppList).toHaveBeenCalled() expect(mockReplace).toHaveBeenCalledWith('/apps') expect(mockSetAppDetail).toHaveBeenCalledWith() }) it('should not delete when appDetail is undefined', async () => { - mockAppDetail = undefined + mockAppDetail.current = undefined const { result } = renderHook(() => useAppInfoActions({})) @@ -479,7 +495,7 @@ describe('useAppInfoActions', () => { }) it('should notify error on delete failure', async () => { - mockDeleteApp.mockRejectedValue({ message: 'cannot delete' }) + mockDeleteApp.mockRejectedValue(new Error('cannot delete')) const { result } = renderHook(() => useAppInfoActions({})) @@ -487,10 +503,7 @@ describe('useAppInfoActions', () => { await result.current.onConfirmDelete() }) - expect(mockNotify).toHaveBeenCalledWith({ - type: 'error', - message: expect.stringContaining('app.appDeleteFailed'), - }) + expect(mockNotify).toHaveBeenCalledWith('app.appDeleteFailed: cannot delete', { type: 'error' }) }) }) }) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown.spec.tsx new file mode 100644 index 0000000000..b80ec46243 --- /dev/null +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown.spec.tsx @@ -0,0 +1,318 @@ +import type { DataSet } from '@/models/datasets' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, +} from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import Dropdown from '../dropdown' + +let mockDataset: DataSet +let mockIsDatasetOperator = false +const mockReplace = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockInvalidDatasetDetail = vi.fn() +const mockExportPipeline = vi.fn() +const mockCheckIsUsedInApp = vi.fn() +const mockDeleteDataset = vi.fn() +const mockToast = vi.fn() +const mockDownloadBlob = vi.fn() + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: { + icon: '๐Ÿ“™', + icon_background: '#FFF4ED', + icon_type: 'emoji', + icon_url: '', + }, + description: 'Dataset description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + created_by: 'user-1', + updated_by: 'user-1', + updated_at: 1690000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 1, + total_document_count: 1, + word_count: 1000, + provider: 'internal', + embedding_model: 'text-embedding-3', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'rag_pipeline', + enable_api: false, + is_multimodal: false, + pipeline_id: 'pipeline-1', + ...overrides, +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => + selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset', 'detail'], + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +vi.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipeline, + }), +})) + +vi.mock('@/service/datasets', () => ({ + checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), + deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +vi.mock('@/app/components/datasets/rename-modal', () => ({ + default: ({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess: () => void + }) => (show + ? ( +
+ + +
+ ) + : null), +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ + isShow, + content, + onConfirm, + onCancel, + }: { + isShow: boolean + content: string + onConfirm: () => void + onCancel: () => void + }) => ( + isShow + ? ( +
+ {content} + + +
+ ) + : null + ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +describe('Dropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataset = createDataset() + mockIsDatasetOperator = false + mockExportPipeline.mockResolvedValue({ data: 'pipeline-yaml' }) + mockDeleteDataset.mockResolvedValue({}) + mockCheckIsUsedInApp.mockResolvedValue({ is_using: true }) + }) + + it('should export the pipeline configuration and download the exported file', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockExportPipeline).toHaveBeenCalledWith({ + pipelineId: 'pipeline-1', + include: false, + }) + }) + expect(mockDownloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'Dataset Name.pipeline', + }) + }) + + it('should confirm deletion with usage-aware copy and delete the dataset after confirmation', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(mockCheckIsUsedInApp).toHaveBeenCalledWith('dataset-1') + }) + expect(screen.getByText('dataset.datasetUsedByApp')).toBeInTheDocument() + + await user.click(screen.getByText('confirm')) + + await waitFor(() => { + expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') + }) + expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + + it('should open the rename modal and hide delete actions for dataset operators', async () => { + const user = userEvent.setup() + mockIsDatasetOperator = true + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.edit')) + + expect(screen.getByTestId('rename-modal')).toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + + await user.click(screen.getByText('rename-success')) + + expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect(mockInvalidDatasetDetail).toHaveBeenCalledTimes(1) + + await user.click(screen.getByText('rename-close')) + + await waitFor(() => { + expect(screen.queryByTestId('rename-modal')).not.toBeInTheDocument() + }) + }) + + it('should show the standard delete confirmation copy and close the dialog on cancel', async () => { + const user = userEvent.setup() + mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument() + }) + + await user.click(screen.getByText('cancel')) + + await waitFor(() => { + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) + }) + + it('should show an error toast when dataset usage detection fails', async () => { + const user = userEvent.setup() + const json = vi.fn().mockResolvedValue({ message: 'Dataset still linked' }) + mockCheckIsUsedInApp.mockRejectedValue({ json }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('common.operation.delete')) + + await waitFor(() => { + expect(json).toHaveBeenCalledTimes(1) + }) + expect(mockToast).toHaveBeenCalledWith('Dataset still linked', { type: 'error' }) + }) + + it('should not export anything when the dataset has no pipeline id', async () => { + const user = userEvent.setup() + mockDataset = createDataset({ pipeline_id: undefined }) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + expect(mockExportPipeline).not.toHaveBeenCalled() + expect(mockDownloadBlob).not.toHaveBeenCalled() + }) + + it('should show an error toast when pipeline export fails', async () => { + const user = userEvent.setup() + mockExportPipeline.mockRejectedValue(new Error('export failed')) + + render() + + await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith('app.exportFailed', { type: 'error' }) + }) + }) +}) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/menu-item.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/menu-item.spec.tsx new file mode 100644 index 0000000000..a6d8b1e2e8 --- /dev/null +++ b/web/app/components/app-sidebar/dataset-info/__tests__/menu-item.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MenuItem from '../menu-item' + +const TestIcon = ({ className }: { className?: string }) => ( + +) + +describe('MenuItem', () => { + it('should stop propagation and invoke the click handler', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + const parentClick = vi.fn() + + render( +
+ +
, + ) + + await user.click(screen.getByText('Edit')) + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/menu.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/menu.spec.tsx new file mode 100644 index 0000000000..4af1223215 --- /dev/null +++ b/web/app/components/app-sidebar/dataset-info/__tests__/menu.spec.tsx @@ -0,0 +1,145 @@ +import type { DataSet } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { + ChunkingMode, + DatasetPermission, + DataSourceType, +} from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import Menu from '../menu' + +let mockDataset: Partial + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: { + icon: '๐Ÿ“™', + icon_background: '#FFF4ED', + icon_type: 'emoji', + icon_url: '', + }, + description: 'Dataset description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + created_by: 'user-1', + updated_by: 'user-1', + updated_at: 1690000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 1, + total_document_count: 1, + word_count: 1000, + provider: 'internal', + embedding_model: 'text-embedding-3', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'rag_pipeline', + enable_api: false, + is_multimodal: false, + ...overrides, +}) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: Partial }) => unknown) => + selector({ dataset: mockDataset }), +})) + +describe('Menu', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataset = createDataset() + }) + + it('should render edit, export, and delete actions for rag pipeline datasets', () => { + render( + , + ) + + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument() + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + }) + + it('should hide export action when the dataset is not a rag pipeline dataset', () => { + mockDataset = createDataset({ runtime_mode: 'general' }) + + render( + , + ) + + expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument() + }) + + it('should invoke menu callbacks when actions are clicked', async () => { + const user = userEvent.setup() + const openRenameModal = vi.fn() + const handleExportPipeline = vi.fn() + const detectIsUsedByApp = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('common.operation.edit')) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + await user.click(screen.getByText('common.operation.delete')) + + expect(openRenameModal).toHaveBeenCalledTimes(1) + expect(handleExportPipeline).toHaveBeenCalledTimes(1) + expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index 59c36967b5..4e80bb8338 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -220,6 +220,7 @@ export type WorkflowToolProviderOutputSchema = { export type WorkflowToolProviderRequest = { name: string + label: string icon: Emoji description: string parameters: WorkflowToolProviderParameter[] diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx new file mode 100644 index 0000000000..1d9f80eebd --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -0,0 +1,229 @@ +import type { WorkflowToolModalPayload } from '../index' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import WorkflowToolAsModal from '../index' + +const mockToastNotify = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (message: string) => mockToastNotify({ type: 'error', message }), + }, +})) + +vi.mock('@/app/components/base/drawer-plus', () => ({ + default: ({ body }: { body: React.ReactNode }) => ( +
{body}
+ ), +})) + +vi.mock('@/app/components/base/emoji-picker', () => ({ + default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ onClick, icon, background }: { onClick?: () => void, icon: string, background: string }) => ( + + ), +})) + +vi.mock('@/app/components/tools/labels/selector', () => ({ + default: ({ value, onChange }: { value: string[], onChange: (value: string[]) => void }) => ( +
+ {value.join(',')} + +
+ ), +})) + +vi.mock('../method-selector', () => ({ + default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('../confirm-modal', () => ({ + default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => ( + show + ? ( +
+ + +
+ ) + : null + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +const createPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ + icon: { + content: '๐Ÿ”ง', + background: '#ffffff', + }, + label: 'Test Tool', + name: 'test_tool', + description: 'Test description', + parameters: [ + { + name: 'param1', + description: 'Parameter 1', + form: 'llm', + required: true, + type: 'string', + }, + ], + outputParameters: [ + { + name: 'text', + description: 'Duplicate output', + type: 'string', + }, + { + name: 'result', + description: 'Result output', + type: 'string', + }, + ], + labels: ['label1'], + privacy_policy: 'https://example.com/privacy', + workflow_app_id: 'workflow-app-123', + workflow_tool_id: 'workflow-tool-456', + ...overrides, +}) + +describe('WorkflowToolAsModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should submit create payload with updated form state in add mode', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + + render( + , + ) + + const titleInput = screen.getByDisplayValue('Test Tool') + const nameInput = screen.getByDisplayValue('test_tool') + const descriptionInput = screen.getByDisplayValue('Test description') + const parameterDescriptionInput = screen.getByDisplayValue('Parameter 1') + + await user.click(screen.getByTestId('app-icon')) + await user.click(screen.getByText('select emoji')) + await user.clear(titleInput) + await user.type(titleInput, 'Updated Tool') + await user.clear(nameInput) + await user.type(nameInput, 'updated_tool') + await user.clear(descriptionInput) + await user.type(descriptionInput, 'Updated description') + await user.selectOptions(screen.getByLabelText('parameter method'), 'form') + await user.clear(parameterDescriptionInput) + await user.type(parameterDescriptionInput, 'Updated parameter') + await user.click(screen.getByText('add label')) + await user.click(screen.getByText('common.operation.save')) + + expect(onCreate).toHaveBeenCalledWith({ + description: 'Updated description', + icon: { + content: '๐Ÿš€', + background: '#f0f0f0', + }, + label: 'Updated Tool', + labels: ['label1', 'new-label'], + name: 'updated_tool', + parameters: [ + { + name: 'param1', + description: 'Updated parameter', + form: 'form', + }, + ], + privacy_policy: 'https://example.com/privacy', + workflow_app_id: 'workflow-app-123', + }) + }) + + it('should block submission and show an error when the tool-call name is invalid', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + + render( + , + ) + + const nameInput = screen.getByDisplayValue('test_tool') + await user.clear(nameInput) + await user.type(nameInput, 'bad-name') + await user.click(screen.getByText('common.operation.save')) + + expect(onCreate).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'tools.createTool.nameForToolCalltools.createTool.nameForToolCallTip', + }) + }) + + it('should require confirmation before saving in edit mode', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('common.operation.save')) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + await user.click(screen.getByText('confirm save')) + + expect(onSave).toHaveBeenCalledWith({ + description: 'Test description', + icon: { + content: '๐Ÿ”ง', + background: '#ffffff', + }, + label: 'Test Tool', + labels: ['label1'], + name: 'test_tool', + parameters: [ + { + name: 'param1', + description: 'Parameter 1', + form: 'llm', + }, + ], + privacy_policy: 'https://example.com/privacy', + workflow_tool_id: 'workflow-tool-456', + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/__tests__/use-workflow-tool-form.spec.ts b/web/app/components/tools/workflow-tool/__tests__/use-workflow-tool-form.spec.ts new file mode 100644 index 0000000000..adb712d0a1 --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/use-workflow-tool-form.spec.ts @@ -0,0 +1,112 @@ +import type { WorkflowToolModalPayload } from '../index' +import { act, renderHook } from '@testing-library/react' +import { useWorkflowToolForm } from '../use-workflow-tool-form' + +const mockToastError = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +const createPayload = (): WorkflowToolModalPayload => ({ + icon: { + content: '๐Ÿ”ง', + background: '#ffffff', + }, + label: 'Test Tool', + name: 'test_tool', + description: 'Test description', + parameters: [ + { + name: 'param1', + description: 'Parameter 1', + form: 'llm', + required: true, + type: 'string', + }, + ], + outputParameters: [ + { + name: 'result', + description: 'Result output', + type: 'string', + }, + ], + labels: ['label1'], + privacy_policy: 'https://example.com/privacy', + workflow_app_id: 'workflow-app-123', + workflow_tool_id: 'workflow-tool-456', +}) + +describe('useWorkflowToolForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should submit create payload immediately in add mode', () => { + const onCreate = vi.fn() + const { result } = renderHook(() => useWorkflowToolForm({ + isAdd: true, + onCreate, + payload: createPayload(), + })) + + act(() => { + result.current.handlePrimaryAction() + }) + + expect(onCreate).toHaveBeenCalledWith({ + description: 'Test description', + icon: { + content: '๐Ÿ”ง', + background: '#ffffff', + }, + label: 'Test Tool', + labels: ['label1'], + name: 'test_tool', + parameters: [ + { + name: 'param1', + description: 'Parameter 1', + form: 'llm', + }, + ], + privacy_policy: 'https://example.com/privacy', + workflow_app_id: 'workflow-app-123', + }) + }) + + it('should open the confirmation modal in edit mode before saving', () => { + const { result } = renderHook(() => useWorkflowToolForm({ + onSave: vi.fn(), + payload: createPayload(), + })) + + act(() => { + result.current.handlePrimaryAction() + }) + + expect(result.current.showConfirmModal).toBe(true) + }) + + it('should report validation errors when the tool-call name is invalid', () => { + const onCreate = vi.fn() + const { result } = renderHook(() => useWorkflowToolForm({ + isAdd: true, + onCreate, + payload: createPayload(), + })) + + act(() => { + result.current.setName('bad-name') + }) + act(() => { + result.current.handleConfirm() + }) + + expect(onCreate).not.toHaveBeenCalled() + expect(mockToastError).toHaveBeenCalledWith('tools.createTool.nameForToolCalltools.createTool.nameForToolCallTip') + }) +}) diff --git a/web/app/components/tools/workflow-tool/__tests__/workflow-tool-form.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/workflow-tool-form.spec.tsx new file mode 100644 index 0000000000..d48e9f7af7 --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/workflow-tool-form.spec.tsx @@ -0,0 +1,153 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { VarType } from '@/app/components/workflow/types' +import WorkflowToolForm from '../workflow-tool-form' + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ onClick }: { onClick?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/tools/labels/selector', () => ({ + default: ({ value, onChange }: { value: string[], onChange: (value: string[]) => void }) => ( + + ), +})) + +vi.mock('../method-selector', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render?: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +describe('WorkflowToolForm', () => { + it('should wire form callbacks from the rendered controls', async () => { + const user = userEvent.setup() + const onTitleChange = vi.fn() + const onNameChange = vi.fn() + const onDescriptionChange = vi.fn() + const onParameterChange = vi.fn() + const onLabelChange = vi.fn() + const onPrivacyPolicyChange = vi.fn() + const onEmojiClick = vi.fn() + const onHide = vi.fn() + const onPrimaryAction = vi.fn() + const onRemove = vi.fn() + + render( + , + ) + + await user.click(screen.getByTestId('app-icon')) + await user.type(screen.getByDisplayValue('Test Tool'), '!') + await user.type(screen.getByDisplayValue('test_tool'), '!') + await user.type(screen.getByDisplayValue('Test description'), '!') + await user.click(screen.getByText('change method')) + await user.type(screen.getByDisplayValue('Parameter 1'), '!') + await user.click(screen.getByText('labels')) + await user.type(screen.getByDisplayValue('https://example.com/privacy'), '/policy') + await user.click(screen.getByText('common.operation.cancel')) + await user.click(screen.getByText('common.operation.save')) + await user.click(screen.getByText('common.operation.delete')) + + expect(onEmojiClick).toHaveBeenCalledTimes(1) + expect(onTitleChange).toHaveBeenCalled() + expect(onNameChange).toHaveBeenCalled() + expect(onDescriptionChange).toHaveBeenCalled() + expect(onParameterChange).toHaveBeenCalled() + expect(onLabelChange).toHaveBeenCalledWith(['label1', 'new-label']) + expect(onPrivacyPolicyChange).toHaveBeenCalled() + expect(onHide).toHaveBeenCalledTimes(1) + expect(onPrimaryAction).toHaveBeenCalledTimes(1) + expect(onRemove).toHaveBeenCalledTimes(1) + expect(screen.getByText('tools.createTool.toolOutput.reservedParameterDuplicateTip')).toBeInTheDocument() + }) + + it('should render image parameters without a method selector in add mode', () => { + render( + , + ) + + expect(screen.getByText('tools.createTool.nameForToolCallTip')).toBeInTheDocument() + expect(screen.getByText('tools.createTool.toolInput.methodParameter')).toBeInTheDocument() + expect(screen.queryByText('change method')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + expect(screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 947b8d732a..794bcb610b 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -1,55 +1,17 @@ 'use client' import type { FC } from 'react' -import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' -import { RiErrorWarningLine } from '@remixicon/react' -import { produce } from 'immer' +import type { WorkflowToolModalProps } from './types' import * as React from 'react' -import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import AppIcon from '@/app/components/base/app-icon' -import Button from '@/app/components/base/button' import Drawer from '@/app/components/base/drawer-plus' import EmojiPicker from '@/app/components/base/emoji-picker' -import Input from '@/app/components/base/input' -import Textarea from '@/app/components/base/textarea' -import Tooltip from '@/app/components/base/tooltip' -import { toast } from '@/app/components/base/ui/toast' -import LabelSelector from '@/app/components/tools/labels/selector' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' -import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' -import { VarType } from '@/app/components/workflow/types' -import { cn } from '@/utils/classnames' -import { buildWorkflowOutputParameters } from './utils' +import { useWorkflowToolForm } from './use-workflow-tool-form' +import WorkflowToolForm from './workflow-tool-form' -export type WorkflowToolModalPayload = { - icon: Emoji - label: string - name: string - description: string - parameters: WorkflowToolProviderParameter[] - outputParameters: WorkflowToolProviderOutputParameter[] - labels: string[] - privacy_policy: string - tool?: { - output_schema?: WorkflowToolProviderOutputSchema - } - workflow_tool_id?: string - workflow_app_id?: string -} +export type { WorkflowToolModalPayload } from './types' -type Props = { - isAdd?: boolean - payload: WorkflowToolModalPayload - onHide: () => void - onRemove?: () => void - onCreate?: (payload: WorkflowToolProviderRequest & { workflow_app_id: string }) => void - onSave?: (payload: WorkflowToolProviderRequest & Partial<{ - workflow_app_id: string - workflow_tool_id: string - }>) => void -} -// Add and Edit -const WorkflowToolAsModal: FC = ({ +const WorkflowToolAsModal: FC = ({ isAdd, payload, onHide, @@ -58,107 +20,35 @@ const WorkflowToolAsModal: FC = ({ onCreate, }) => { const { t } = useTranslation() - - const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const [emoji, setEmoji] = useState(payload.icon) - const [label, setLabel] = useState(payload.label) - const [name, setName] = useState(payload.name) - const [description, setDescription] = useState(payload.description) - const [parameters, setParameters] = useState(payload.parameters) - const rawOutputParameters = payload.outputParameters - const outputSchema = payload.tool?.output_schema - const outputParameters = useMemo(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema]) - const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [ - { - name: 'text', - description: t('nodes.tool.outputVars.text', { ns: 'workflow' }), - type: VarType.string, - reserved: true, - }, - { - name: 'files', - description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }), - type: VarType.arrayFile, - reserved: true, - }, - { - name: 'json', - description: t('nodes.tool.outputVars.json', { ns: 'workflow' }), - type: VarType.arrayObject, - reserved: true, - }, - ] - - const handleParameterChange = (key: string, value: string, index: number) => { - const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => { - if (key === 'description') - draft[index].description = value - else - draft[index].form = value - }) - setParameters(newData) - } - const [labels, setLabels] = useState(payload.labels) - const handleLabelSelect = (value: string[]) => { - setLabels(value) - } - const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) - const [showModal, setShowModal] = useState(false) - - const isNameValid = (name: string) => { - // when the user has not input anything, no need for a warning - if (name === '') - return true - - return /^\w+$/.test(name) - } - - const isOutputParameterReserved = (name: string) => { - return reservedOutputParameters.find(p => p.name === name) - } - - const onConfirm = () => { - let errorMessage = '' - if (!label) - errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.name', { ns: 'tools' }) }) - - if (!name) - errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }) - - if (!isNameValid(name)) - errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }) - - if (errorMessage) { - toast.error(errorMessage) - return - } - - const requestParams = { - name, - description, - icon: emoji, - label, - parameters: parameters.map(item => ({ - name: item.name, - description: item.description, - form: item.form, - })), - labels, - privacy_policy: privacyPolicy, - } - if (!isAdd) { - onSave?.({ - ...requestParams, - workflow_tool_id: payload.workflow_tool_id!, - }) - } - else { - onCreate?.({ - ...requestParams, - workflow_app_id: payload.workflow_app_id!, - }) - } - } + const { + description, + emoji, + handleConfirm, + handleParameterChange, + handlePrimaryAction, + isNameCurrentlyValid, + label, + labels, + name, + outputParameters, + parameters, + privacyPolicy, + setDescription, + setEmoji, + setLabel, + setLabels, + setName, + setPrivacyPolicy, + setShowConfirmModal, + setShowEmojiPicker, + showConfirmModal, + showEmojiPicker, + } = useWorkflowToolForm({ + isAdd, + onCreate, + onSave, + payload, + }) return ( <> @@ -171,195 +61,28 @@ const WorkflowToolAsModal: FC = ({ height="calc(100vh - 16px)" headerClassName="!border-b-divider" body={( -
-
- {/* name & icon */} -
-
- {t('createTool.name', { ns: 'tools' })} - {' '} - * -
-
- { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} /> - setLabel(e.target.value)} - /> -
-
- {/* name for tool call */} -
-
- {t('createTool.nameForToolCall', { ns: 'tools' })} - {' '} - * - - {t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })} -
- )} - /> -
- setName(e.target.value)} - /> - {!isNameValid(name) && ( -
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
- )} -
- {/* description */} -
-
{t('createTool.description', { ns: 'tools' })}
-