fix(web): publish workflow before creating tool (#41528)

This commit is contained in:
QuantumGhost 2026-09-01 07:52:07 +00:00 committed by GitHub
parent 8e064ffe98
commit 27c2f058fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 258 additions and 39 deletions

View File

@ -39,6 +39,12 @@ vi.mock('@/app/components/app/app-publisher', () => ({
<button type="button" onClick={() => props.onPublish?.({ id: 'model-1' })}>
publish-through-wrapper
</button>
<button
type="button"
onClick={() => props.onPublish?.(undefined, { showSuccessToast: false })}
>
publish-silently-through-wrapper
</button>
<button type="button" onClick={() => props.onRestore?.()}>
restore-through-wrapper
</button>
@ -107,6 +113,23 @@ describe('FeaturesWrappedAppPublisher', () => {
})
})
it('should pass publish notification options through to onPublish', async () => {
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
onPublish={mockOnPublish}
/>,
)
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(
<FeaturesWrappedAppPublisher

View File

@ -95,11 +95,17 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
}))
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<string, unknown>) => void
onHide: () => void
}) => (
<div role="dialog" aria-label="Workflow tool drawer">
workflow tool drawer
<button
type="button"
onClick={() =>
onCreate?.({
workflow_app_id: 'app-1',
name: 'workflow_tool',
})
}
>
create-workflow-tool
</button>
<button type="button" onClick={onHide}>
close-workflow-tool-drawer
</button>
@ -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(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />)
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(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />)
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(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />)
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 = {

View File

@ -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<AppPublisherProps, 'onPublish'> & {
onPublish?: (
params?: AppPublisherPublishParams,
features?: Features,
options?: AppPublisherPublishOptions,
) => Promise<unknown> | 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],

View File

@ -130,7 +130,7 @@ export function PublisherContent({
hasTriggerNode,
inputs,
onClosePublisher: closePublisher,
onPublish: publish.handlePublish,
onPublish: publish.publishWorkflowTool,
onRefreshData,
outputs,
toolPublished,

View File

@ -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),
}
}

View File

@ -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> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
| ((
params?: AppPublisherPublishParams,
options?: AppPublisherPublishOptions,
) => Promise<unknown> | unknown)
| ((params?: unknown, options?: AppPublisherPublishOptions) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown

View File

@ -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()
})

View File

@ -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)

View File

@ -182,6 +182,23 @@ vi.mock('@/app/components/app/app-publisher', () => ({
>
publisher-publish
</button>
<button
type="button"
onClick={() => {
Promise.resolve(
(
props.onPublish as
| ((
params?: unknown,
options?: { showSuccessToast?: boolean },
) => Promise<unknown> | unknown)
| undefined
)?.(undefined, { showSuccessToast: false }),
).catch(() => undefined)
}}
>
publisher-publish-silently
</button>
<button
type="button"
onClick={() => {
@ -583,6 +600,21 @@ describe('FeaturesTrigger', () => {
})
})
it('should not show a success toast when the publisher requests a silent publish', async () => {
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
await user.click(screen.getByRole('button', { name: 'publisher-publish-silently' }))
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalled()
})
expect(toastMocks.call).not.toHaveBeenCalledWith({
type: 'success',
message: 'common.api.actionSuccess',
})
})
it('should invalidate roster list after publishing a workflow with a roster Agent v2 node', async () => {
// Arrange
const user = userEvent.setup()

View File

@ -1,4 +1,7 @@
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher/types'
import type {
AppPublisherPublishOptions,
AppPublisherPublishParams,
} from '@/app/components/app/app-publisher/types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { CommonEdgeType, Node } from '@/app/components/workflow/types'
@ -150,7 +153,7 @@ const FeaturesTrigger = () => {
const updatePublishedWorkflow = useInvalidateAppWorkflow()
const onPublish = useCallback(
async (params?: AppPublisherPublishParams) => {
async (params?: AppPublisherPublishParams, options?: AppPublisherPublishOptions) => {
const publishParams = params && 'title' in params ? params : undefined
// First check if there are any items in the checklist
// if (!validateBeforeRun())
@ -172,7 +175,9 @@ const FeaturesTrigger = () => {
releaseNotes: publishParams?.releaseNotes || '',
})
if (res) {
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
if (options?.showSuccessToast !== false) {
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
}
updatePublishedWorkflow(appID!)
updateAppDetail()
invalidateAppTriggers(appID!)