mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
fix(web): publish workflow before creating tool (#41528)
This commit is contained in:
parent
8e064ffe98
commit
27c2f058fe
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -130,7 +130,7 @@ export function PublisherContent({
|
||||
hasTriggerNode,
|
||||
inputs,
|
||||
onClosePublisher: closePublisher,
|
||||
onPublish: publish.handlePublish,
|
||||
onPublish: publish.publishWorkflowTool,
|
||||
onRefreshData,
|
||||
outputs,
|
||||
toolPublished,
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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!)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user