diff --git a/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx b/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx index c51ab48015c..bc074e482ea 100644 --- a/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx @@ -39,6 +39,12 @@ vi.mock('@/app/components/app/app-publisher', () => ({ + @@ -107,6 +113,23 @@ describe('FeaturesWrappedAppPublisher', () => { }) }) + it('should pass publish notification options through to onPublish', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('publish-silently-through-wrapper')) + + await waitFor(() => { + expect(mockOnPublish).toHaveBeenCalledWith(undefined, mockFeatures, { + showSuccessToast: false, + }) + }) + }) + it('should restore published features after confirmation', async () => { render( ({ })) const mockPublishToCreatorsPlatform = vi.fn() +const mockCreateWorkflowToolProvider = vi.fn() vi.mock('@/service/apps', () => ({ publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), })) +vi.mock('@/service/tools', () => ({ + createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args), + saveWorkflowToolProvider: vi.fn(), +})) + vi.mock('@/service/use-workflow', () => ({ useAppWorkflow: () => ({ data: mockPublishedWorkflow, @@ -167,9 +173,26 @@ vi.mock('@/app/components/base/amplitude', () => ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( + WorkflowToolDrawer: ({ + onCreate, + onHide, + }: { + onCreate?: (payload: Record) => void + onHide: () => void + }) => (
workflow tool drawer + @@ -818,6 +841,74 @@ describe('AppPublisher', () => { expect(screen.getByRole('dialog', { name: 'Workflow tool drawer' })).toBeInTheDocument() }) + it('should show one success toast when automatically publishing a workflow tool', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockOnPublish.mockImplementation(async (_params, options?: { showSuccessToast?: boolean }) => { + if (options?.showSuccessToast !== false) mockToastSuccess('common.api.actionSuccess') + }) + mockCreateWorkflowToolProvider.mockResolvedValue({}) + + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + fireEvent.click(screen.getByRole('button', { name: 'create-workflow-tool' })) + + await waitFor(() => { + expect(mockCreateWorkflowToolProvider).toHaveBeenCalledOnce() + }) + expect(mockOnPublish).toHaveBeenCalledWith(undefined, { showSuccessToast: false }) + expect(mockToastSuccess).toHaveBeenCalledOnce() + }) + + it('should not show a success toast when workflow tool creation fails after publishing', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockOnPublish.mockImplementation(async (_params, options?: { showSuccessToast?: boolean }) => { + if (options?.showSuccessToast !== false) mockToastSuccess('common.api.actionSuccess') + }) + mockCreateWorkflowToolProvider.mockRejectedValue(new Error('create failed')) + + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + fireEvent.click(screen.getByRole('button', { name: 'create-workflow-tool' })) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('create failed') + }) + expect(mockOnPublish).toHaveBeenCalledWith(undefined, { showSuccessToast: false }) + expect(mockToastSuccess).not.toHaveBeenCalled() + }) + + it('should not create a workflow tool when automatic publishing fails', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) + + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + fireEvent.click(screen.getByRole('button', { name: 'create-workflow-tool' })) + + await waitFor(() => { + expect(mockOnPublish).toHaveBeenCalledOnce() + }) + expect(mockCreateWorkflowToolProvider).not.toHaveBeenCalled() + expect(mockToastError).toHaveBeenCalledWith('publish failed') + consoleWarnSpy.mockRestore() + }) + it('should not open workflow tool drawer without tool.manage', () => { mockWorkspacePermissionKeys = [] mockAppDetail = { diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 4114aadb50a..ffb85da6b44 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,5 +1,6 @@ import type { AppPublisherProps, + AppPublisherPublishOptions, AppPublisherPublishParams, } from '@/app/components/app/app-publisher/types' import type { ConfigurationPublishConfig } from '@/app/components/app/configuration/hooks/configuration-lifecycle/types' @@ -26,6 +27,7 @@ type Props = Omit & { onPublish?: ( params?: AppPublisherPublishParams, features?: Features, + options?: AppPublisherPublishOptions, ) => Promise | unknown publishedConfig: ConfigurationPublishConfig resetAppConfig?: () => void @@ -88,7 +90,8 @@ const FeaturesWrappedAppPublisher = (props: Props) => { } const handlePublish = useCallback( - (params?: AppPublisherPublishParams) => { + (params?: AppPublisherPublishParams, options?: AppPublisherPublishOptions) => { + if (options) return props.onPublish?.(params, features, options) return props.onPublish?.(params, features) }, [features, props], diff --git a/web/app/components/app/app-publisher/publisher-content/index.tsx b/web/app/components/app/app-publisher/publisher-content/index.tsx index 945e879331b..c5b7e489db1 100644 --- a/web/app/components/app/app-publisher/publisher-content/index.tsx +++ b/web/app/components/app/app-publisher/publisher-content/index.tsx @@ -130,7 +130,7 @@ export function PublisherContent({ hasTriggerNode, inputs, onClosePublisher: closePublisher, - onPublish: publish.handlePublish, + onPublish: publish.publishWorkflowTool, onRefreshData, outputs, toolPublished, diff --git a/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts b/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts index 0fc2f7acb3d..6737b74ee17 100644 --- a/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts +++ b/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts @@ -1,5 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' -import type { AppPublisherProps, AppPublisherPublishParams } from '../types' +import type { + AppPublisherProps, + AppPublisherPublishOptions, + AppPublisherPublishParams, +} from '../types' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import { useHotkey } from '@tanstack/react-hotkeys' import { useQueryClient } from '@tanstack/react-query' @@ -81,43 +85,54 @@ export function usePublishController({ : publishedAt const hasPublishedVersion = Boolean(currentPublishedAt) + async function publishApp( + params?: AppPublisherPublishParams, + options?: AppPublisherPublishOptions, + ) { + await onPublish?.(params, options) + setPublished(true) + + const socket = appId ? webSocketClient.getSocket(appId) : null + if (appId) { + invalidateAppWorkflow(appId) + if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId) + } else { + console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') + } + if (socket) { + const timestamp = Date.now() + socket.emit('collaboration_event', { + type: 'app_publish_update', + data: { + action: 'published', + timestamp, + }, + timestamp, + }) + } else if (appId) { + console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) + } + + trackEvent('app_published_time', { + action_mode: 'app', + app_id: appId, + app_name: appName, + }) + } + async function handlePublish(params?: AppPublisherPublishParams) { try { - await onPublish?.(params) - setPublished(true) - - const socket = appId ? webSocketClient.getSocket(appId) : null - if (appId) { - invalidateAppWorkflow(appId) - if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId) - } else { - console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') - } - if (socket) { - const timestamp = Date.now() - socket.emit('collaboration_event', { - type: 'app_publish_update', - data: { - action: 'published', - timestamp, - }, - timestamp, - }) - } else if (appId) { - console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) - } - - trackEvent('app_published_time', { - action_mode: 'app', - app_id: appId, - app_name: appName, - }) + await publishApp(params) } catch (error) { console.warn('[app-publisher] publish failed', error) setPublished(false) } } + async function publishWorkflowTool(params?: AppPublisherPublishParams) { + await publishApp(params, { showSuccessToast: false }) + } + async function handleRestore() { try { await onRestore?.() @@ -164,6 +179,7 @@ export function usePublishController({ isWorkflowApp, published, publishedWorkflow, + publishWorkflowTool, resetPublished: () => setPublished(false), } } diff --git a/web/app/components/app/app-publisher/types.ts b/web/app/components/app/app-publisher/types.ts index 8c8e9917cde..903d45097f0 100644 --- a/web/app/components/app/app-publisher/types.ts +++ b/web/app/components/app/app-publisher/types.ts @@ -4,9 +4,16 @@ import type { PublishWorkflowParams } from '@/types/workflow' export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams +export type AppPublisherPublishOptions = { + showSuccessToast?: boolean +} + type AppPublisherPublishHandler = - | ((params?: AppPublisherPublishParams) => Promise | unknown) - | ((params?: unknown) => Promise | unknown) + | (( + params?: AppPublisherPublishParams, + options?: AppPublisherPublishOptions, + ) => Promise | unknown) + | ((params?: unknown, options?: AppPublisherPublishOptions) => Promise | unknown) type AppPublisherRestoreHandler = () => Promise | unknown diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index 0f8720f10be..702fd85d77f 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -370,6 +370,46 @@ describe('useConfigureButton', () => { // Mutation handlers describe('handleCreate', () => { + it('should publish before creating the provider', async () => { + mockCreateWorkflowToolProvider.mockResolvedValue({}) + const handlePublish = vi.fn().mockResolvedValue(undefined) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ handlePublish })), + ) + + await act(async () => { + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) + }) + + expect(handlePublish).toHaveBeenCalledOnce() + expect(mockCreateWorkflowToolProvider).toHaveBeenCalledOnce() + expect(handlePublish.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateWorkflowToolProvider.mock.invocationCallOrder[0]!, + ) + }) + + it('should not create the provider when publishing fails', async () => { + const handlePublish = vi.fn().mockRejectedValue(new Error('Publish failed')) + const { result } = renderHook(() => + useConfigureButton(createDefaultOptions({ handlePublish })), + ) + + await act(async () => { + await result.current.handleCreate( + createMockRequest({ workflow_app_id: 'app-123' }) as WorkflowToolProviderRequest & { + workflow_app_id: string + }, + ) + }) + + expect(mockCreateWorkflowToolProvider).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Publish failed' }) + }) + it('should create provider, invalidate caches, refresh, and notify configured', async () => { mockCreateWorkflowToolProvider.mockResolvedValue({}) const onRefreshData = vi.fn() @@ -439,6 +479,7 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') + expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) expect(onConfigured).toHaveBeenCalled() }) diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 6c8e982c624..a0d15c68ac9 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -121,7 +121,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData, onConfigured, } = options - const { t } = useTranslation() // Data fetching via React Query @@ -180,6 +179,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { // Mutation handlers (not memoized — only used in conditionally-rendered modal) const handleCreate = async (data: WorkflowToolProviderRequest & { workflow_app_id: string }) => { try { + await handlePublish() await createWorkflowToolProvider(data) invalidateAllWorkflowTools() onRefreshData?.() @@ -204,6 +204,7 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateAllWorkflowTools() invalidateDetail(workflowAppId) + toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' })) onConfigured?.() } catch (e) { toast.error((e as Error).message) diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index cafae7b2526..ea6550b541c 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -182,6 +182,23 @@ vi.mock('@/app/components/app/app-publisher', () => ({ > publisher-publish +