diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index e97efaa5259..137d028a3f6 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' +import { AppModeEnum, AppTypeEnum } from '@/types/app' import { basePath } from '@/utils/var' import AppPublisher from '../index' @@ -15,6 +15,7 @@ const mockOpenAsyncWindow = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() +const mockConvertWorkflowType = vi.fn() const sectionProps = vi.hoisted(() => ({ summary: null as null | Record, @@ -88,6 +89,13 @@ vi.mock('@/service/apps', () => ({ fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), })) +vi.mock('@/service/use-apps', () => ({ + useConvertWorkflowTypeMutation: () => ({ + mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args), + isPending: false, + }), +})) + vi.mock('@/app/components/base/ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), @@ -124,15 +132,15 @@ vi.mock('@/app/components/base/portal-to-follow-elem', async () => { return { PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( - +
{children}
-
+ ), PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
{children}
), PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { - const open = ReactModule.useContext(OpenContext) + const open = ReactModule.use(OpenContext) return open ?
{children}
: null }, } @@ -145,6 +153,7 @@ vi.mock('../sections', () => ({
+
) }, @@ -175,6 +184,7 @@ describe('AppPublisher', () => { name: 'Demo App', mode: AppModeEnum.CHAT, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + type: AppTypeEnum.WORKFLOW, site: { app_base_url: 'https://example.com', access_token: 'token-1', @@ -187,6 +197,7 @@ describe('AppPublisher', () => { id: 'app-1', access_mode: AccessMode.PUBLIC, }) + mockConvertWorkflowType.mockResolvedValue({}) mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { await resolver() }) @@ -452,4 +463,58 @@ describe('AppPublisher', () => { }) expect(screen.getByTestId('access-control')).toBeInTheDocument() }) + + it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => { + mockFetchAppDetailDirect.mockResolvedValueOnce({ + id: 'app-1', + type: AppTypeEnum.EVALUATION, + }) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-switch-workflow-type')) + + await waitFor(() => { + expect(mockConvertWorkflowType).toHaveBeenCalledWith({ + params: { appId: 'app-1' }, + query: { target_type: AppTypeEnum.EVALUATION }, + }) + expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(mockSetAppDetail).toHaveBeenCalledWith({ + id: 'app-1', + type: AppTypeEnum.EVALUATION, + }) + }) + expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() + }) + + it('should hide access and actions sections for evaluation workflow apps', () => { + mockAppDetail = { + ...mockAppDetail, + type: AppTypeEnum.EVALUATION, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + + expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument() + expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument() + expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument() + expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({ + targetType: AppTypeEnum.WORKFLOW, + publishLabelKey: 'common.publishAsStandardWorkflow', + switchLabelKey: 'common.switchToStandardWorkflow', + tipKey: 'common.switchToStandardWorkflowTip', + }) + }) }) diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 57e7a55b131..87155e6aa6d 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -45,12 +45,14 @@ describe('app-publisher sections', () => { handleRestore={handleRestore} isChatApp multipleModelConfigs={[]} + onWorkflowTypeSwitch={vi.fn()} publishDisabled={false} published={false} publishedAt={Date.now()} publishShortcut={['ctrl', '⇧', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} + workflowTypeSwitchDisabled={false} />, ) @@ -83,12 +85,14 @@ describe('app-publisher sections', () => { handleRestore={vi.fn()} isChatApp={false} multipleModelConfigs={[]} + onWorkflowTypeSwitch={vi.fn()} publishDisabled={false} published={false} publishedAt={undefined} publishShortcut={['ctrl', '⇧', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} + workflowTypeSwitchDisabled={false} />, ) @@ -107,12 +111,14 @@ describe('app-publisher sections', () => { handleRestore={vi.fn()} isChatApp={false} multipleModelConfigs={[{ id: '1' } as any]} + onWorkflowTypeSwitch={vi.fn()} publishDisabled={false} published={false} publishedAt={undefined} publishShortcut={['ctrl', '⇧', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} + workflowTypeSwitchDisabled={false} />, ) @@ -131,18 +137,54 @@ describe('app-publisher sections', () => { handleRestore={vi.fn()} isChatApp={false} multipleModelConfigs={[]} + onWorkflowTypeSwitch={vi.fn()} publishDisabled={false} published={false} publishedAt={undefined} publishShortcut={['ctrl', '⇧', 'P']} startNodeLimitExceeded upgradeHighlightStyle={{}} + workflowTypeSwitchDisabled={false} />, ) expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument() }) + it('should render workflow type switch action and call switch handler', () => { + const onWorkflowTypeSwitch = vi.fn() + + render( + '1 minute ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + multipleModelConfigs={[]} + onWorkflowTypeSwitch={onWorkflowTypeSwitch} + publishDisabled={false} + published={false} + publishedAt={undefined} + publishShortcut={['ctrl', '⇧', 'P']} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + workflowTypeSwitchConfig={{ + targetType: 'evaluation', + publishLabelKey: 'common.publishAsEvaluationWorkflow', + switchLabelKey: 'common.switchToEvaluationWorkflow', + tipKey: 'common.switchToEvaluationWorkflowTip', + }} + workflowTypeSwitchDisabled={false} + />, + ) + + fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow')) + + expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1) + }) + it('should render loading access state and access mode labels when enabled', () => { const { rerender } = render( + +const WORKFLOW_TYPE_SWITCH_CONFIG: Record = { + workflow: { + targetType: 'evaluation', publishLabelKey: 'common.publishAsEvaluationWorkflow', switchLabelKey: 'common.switchToEvaluationWorkflow', tipKey: 'common.switchToEvaluationWorkflowTip', }, - [AppTypeEnum.EVALUATION]: { - targetType: AppTypeEnum.WORKFLOW, + evaluation: { + targetType: 'workflow', publishLabelKey: 'common.publishAsStandardWorkflow', switchLabelKey: 'common.switchToStandardWorkflow', tipKey: 'common.switchToStandardWorkflowTip', }, } as const +const isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => { + return type === 'workflow' || type === 'evaluation' +} + const AppPublisher = ({ disabled = false, publishDisabled = false, @@ -131,7 +134,9 @@ const AppPublisher = ({ const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) - const workflowTypeSwitchConfig = appDetail?.type ? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type as keyof typeof WORKFLOW_TYPE_SWITCH_CONFIG] : undefined + const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type) + ? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type] + : undefined const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) @@ -230,7 +235,7 @@ const AppPublisher = ({ appId: appDetail.id, }, query: { - target_type: workflowTypeSwitchConfig.targetType as WorkflowTypeConversionTarget, + target_type: workflowTypeSwitchConfig.targetType, }, }) @@ -304,37 +309,44 @@ const AppPublisher = ({ publishShortcut={PUBLISH_SHORTCUT} startNodeLimitExceeded={startNodeLimitExceeded} upgradeHighlightStyle={upgradeHighlightStyle} + workflowTypeSwitchConfig={workflowTypeSwitchConfig} + workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType} + onWorkflowTypeSwitch={handleWorkflowTypeSwitch} /> - setShowAppAccessControl(true)} - /> - { - setEmbeddingModalOpen(true) - handleTrigger() - }} - handleOpenInExplore={handleOpenInExplore} - handlePublish={handlePublish} - hasHumanInputNode={hasHumanInputNode} - hasTriggerNode={hasTriggerNode} - inputs={inputs} - missingStartNode={missingStartNode} - onRefreshData={onRefreshData} - outputs={outputs} - published={published} - publishedAt={publishedAt} - toolPublished={toolPublished} - workflowToolAvailable={workflowToolAvailable} - workflowToolMessage={workflowToolMessage} - /> + {!isEvaluationWorkflowType && ( + <> + setShowAppAccessControl(true)} + /> + { + setEmbeddingModalOpen(true) + handleTrigger() + }} + handleOpenInExplore={handleOpenInExplore} + handlePublish={handlePublish} + hasHumanInputNode={hasHumanInputNode} + hasTriggerNode={hasTriggerNode} + inputs={inputs} + missingStartNode={missingStartNode} + onRefreshData={onRefreshData} + outputs={outputs} + published={published} + publishedAt={publishedAt} + toolPublished={toolPublished} + workflowToolAvailable={workflowToolAvailable} + workflowToolMessage={workflowToolMessage} + /> + + )} + type SummarySectionProps = Pick Promise handleRestore: () => Promise isChatApp: boolean + onWorkflowTypeSwitch: () => Promise published: boolean publishShortcut: string[] upgradeHighlightStyle: CSSProperties + workflowTypeSwitchConfig?: { + targetType: WorkflowTypeConversionTarget + publishLabelKey: WorkflowTypeSwitchLabelKey + switchLabelKey: WorkflowTypeSwitchLabelKey + tipKey: WorkflowTypeSwitchLabelKey + } + workflowTypeSwitchDisabled: boolean } type AccessSectionProps = { @@ -98,12 +108,15 @@ export const PublisherSummarySection = ({ handleRestore, isChatApp, multipleModelConfigs = [], + onWorkflowTypeSwitch, publishDisabled = false, published, publishedAt, publishShortcut, startNodeLimitExceeded = false, upgradeHighlightStyle, + workflowTypeSwitchConfig, + workflowTypeSwitchDisabled, }: SummarySectionProps) => { const { t } = useTranslation() @@ -164,6 +177,45 @@ export const PublisherSummarySection = ({ )} + {workflowTypeSwitchConfig && ( + + )} {startNodeLimitExceeded && (

} + icon={} > {t('common.embedIntoSite', { ns: 'workflow' })} diff --git a/web/app/components/base/icons/src/vender/line/development/index.ts b/web/app/components/base/icons/src/vender/line/development/index.ts index 7c3c48aa5e9..4278370eec9 100644 --- a/web/app/components/base/icons/src/vender/line/development/index.ts +++ b/web/app/components/base/icons/src/vender/line/development/index.ts @@ -1,2 +1 @@ export { default as BracketsX } from './BracketsX' -export { default as CodeBrowser } from './CodeBrowser'