diff --git a/web/contract/console/apps.ts b/web/contract/console/apps.ts index 4fbcfec0cf..00f730977e 100644 --- a/web/contract/console/apps.ts +++ b/web/contract/console/apps.ts @@ -1,3 +1,4 @@ +import type { WorkflowTypeConversionTarget } from '@/types/workflow' import { type } from '@orpc/contract' import { base } from '../base' @@ -12,3 +13,18 @@ export const appDeleteContract = base } }>()) .output(type()) + +export const appWorkflowTypeConvertContract = base + .route({ + path: '/apps/{appId}/workflows/convert-type', + method: 'POST', + }) + .input(type<{ + params: { + appId: string + } + query: { + target_type: WorkflowTypeConversionTarget + } + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index e07f8e5167..c7c2d2ad87 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -1,5 +1,5 @@ import type { InferContractRouterInputs } from '@orpc/contract' -import { appDeleteContract } from './console/apps' +import { appDeleteContract, appWorkflowTypeConvertContract } from './console/apps' import { bindPartnerStackContract, invoicesContract } from './console/billing' import { availableEvaluationMetricsContract, @@ -98,6 +98,7 @@ export const consoleRouterContract = { systemFeatures: systemFeaturesContract, apps: { deleteApp: appDeleteContract, + convertWorkflowType: appWorkflowTypeConvertContract, }, explore: { apps: exploreAppsContract, diff --git a/web/service/__tests__/use-apps.spec.tsx b/web/service/__tests__/use-apps.spec.tsx new file mode 100644 index 0000000000..76d7b87943 --- /dev/null +++ b/web/service/__tests__/use-apps.spec.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useConvertWorkflowTypeMutation } from '../use-apps' + +const { + invalidateQueries, + convertWorkflowTypeMutationFn, + convertWorkflowTypeMutationOptions, +} = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), + convertWorkflowTypeMutationFn: vi.fn(), + convertWorkflowTypeMutationOptions: vi.fn(), +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries, + }), + } +}) + +vi.mock('@/service/client', () => ({ + consoleClient: {}, + consoleQuery: { + apps: { + convertWorkflowType: { + mutationOptions: convertWorkflowTypeMutationOptions, + }, + }, + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +// Scenario: workflow type conversion forwards the expected API input and refreshes app caches. +describe('useConvertWorkflowTypeMutation', () => { + beforeEach(() => { + vi.clearAllMocks() + convertWorkflowTypeMutationFn.mockResolvedValue({}) + convertWorkflowTypeMutationOptions.mockImplementation(options => ({ + mutationFn: convertWorkflowTypeMutationFn, + onSuccess: options?.onSuccess, + })) + }) + + it('should convert workflow type and invalidate app queries when mutation succeeds', async () => { + // Arrange + const { result } = renderHook(() => useConvertWorkflowTypeMutation(), { + wrapper: createWrapper(), + }) + + // Act + await act(async () => { + result.current.mutate({ + params: { appId: 'app-1' }, + query: { target_type: 'evaluation' }, + }) + }) + + // Assert + await waitFor(() => { + expect(convertWorkflowTypeMutationFn).toHaveBeenCalledWith({ + params: { appId: 'app-1' }, + query: { target_type: 'evaluation' }, + }) + }) + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['apps', 'detail', 'app-1'], + }) + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['apps', 'list'], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['apps', 'full-list'], + }) + }) +}) diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index f28df7bd4b..b9b920e848 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -11,6 +11,7 @@ import type { WorkflowDailyConversationsResponse, } from '@/models/app' import type { App } from '@/types/app' +import type { WorkflowTypeConversionTarget } from '@/types/workflow' import { keepPreviousData, useInfiniteQuery, @@ -148,6 +149,30 @@ export const useDeleteAppMutation = () => { }) } +export const useConvertWorkflowTypeMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + ...consoleQuery.apps.convertWorkflowType.mutationOptions({ + onSuccess: async (_, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'detail', variables.params.appId], + }), + queryClient.invalidateQueries({ + queryKey: [NAME_SPACE, 'list'], + }), + queryClient.invalidateQueries({ + queryKey: useAppFullListKey, + }), + ]) + }, + }), + }) +} + +export type { WorkflowTypeConversionTarget } + const useAppStatisticsQuery = (metric: string, appId: string, params?: DateRangeParams) => { return useQuery({ queryKey: [NAME_SPACE, 'statistics', metric, appId, params], diff --git a/web/types/app.ts b/web/types/app.ts index b782a78730..440328af65 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -47,6 +47,14 @@ export enum AppModeEnum { } export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEnum.CHAT, AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT] as const +export enum AppTypeEnum { + WORKFLOW = 'workflow', + CHAT = 'chat', + RAG_PIPELINE = 'rag-pipeline', + SNIPPET = 'snippet', + EVALUATION = 'evaluation', +} + /** * Variable type */ @@ -325,6 +333,8 @@ export type App = { /** Mode */ mode: AppModeEnum + /** Type */ + type?: AppTypeEnum /** Enable web app */ enable_site: boolean /** Enable web API */ diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 9e7dfd7e7a..fc457e602e 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -427,6 +427,8 @@ export type PublishWorkflowParams = { releaseNotes: string } +export type WorkflowTypeConversionTarget = 'workflow' | 'evaluation' + export type UpdateWorkflowParams = { url: string title: string