mirror of
https://github.com/langgenius/dify.git
synced 2026-06-11 02:31:13 +08:00
fix(web): unify workflow node single-run actions (#37262)
This commit is contained in:
parent
d849d60822
commit
8430255931
@ -10,15 +10,13 @@ import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { ChangeBlockMenuTrigger } from '../change-block-menu-trigger'
|
||||
@ -44,11 +42,9 @@ vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@ -66,11 +62,9 @@ vi.mock('@/service/use-tools', () => ({
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseHooksStore = vi.mocked(useHooksStore)
|
||||
const mockUseNodes = vi.mocked(useNodes)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
@ -78,9 +72,11 @@ const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
function renderDropdownContent({
|
||||
showHelpLink = true,
|
||||
onClose = vi.fn(),
|
||||
data = {},
|
||||
}: {
|
||||
showHelpLink?: boolean
|
||||
onClose?: () => void
|
||||
data?: Record<string, unknown>
|
||||
} = {}) {
|
||||
return renderWorkflowFlowComponent(
|
||||
<DropdownMenu open>
|
||||
@ -88,7 +84,7 @@ function renderDropdownContent({
|
||||
<DropdownMenuContent>
|
||||
<NodeActionsDropdownContent
|
||||
id="node-1"
|
||||
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
|
||||
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '', ...data } as any}
|
||||
onClose={onClose}
|
||||
showHelpLink={showHelpLink}
|
||||
/>
|
||||
@ -107,8 +103,6 @@ describe('node actions menu details', () => {
|
||||
const handleNodesDuplicate = vi.fn()
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodesCopy = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -121,10 +115,6 @@ describe('node actions menu details', () => {
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
@ -141,11 +131,6 @@ describe('node actions menu details', () => {
|
||||
handleNodesCopy,
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
} as ReturnType<typeof useNodesSyncDraft>)
|
||||
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
|
||||
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
|
||||
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
|
||||
@ -224,7 +209,7 @@ describe('node actions menu details', () => {
|
||||
|
||||
it('should run, copy, duplicate, delete, and expose the help link', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDropdownContent()
|
||||
const { store } = renderDropdownContent()
|
||||
|
||||
const deleteMenuItem = screen.getByText('common.operation.delete').closest('[role="menuitem"]')
|
||||
expect(deleteMenuItem).toHaveAttribute('data-variant', 'default')
|
||||
@ -237,14 +222,29 @@ describe('node actions menu details', () => {
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(store.getState().initShowLastRunTab).toBe(true)
|
||||
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'run' })
|
||||
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
|
||||
expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
|
||||
})
|
||||
|
||||
it('should stop the current single run from the run action when the node is running', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderDropdownContent({
|
||||
data: {
|
||||
_singleRunningStatus: NodeRunningStatus.Running,
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('workflow.debug.variableInspect.trigger.stop'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(store.getState().initShowLastRunTab).toBe(true)
|
||||
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'stop' })
|
||||
})
|
||||
|
||||
it('should hide change action when node is undeletable', () => {
|
||||
mockUseNodeMetaData.mockReturnValueOnce({
|
||||
isTypeFixed: false,
|
||||
|
||||
@ -4,11 +4,9 @@ import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
@ -18,11 +16,9 @@ vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@ -34,11 +30,9 @@ vi.mock('../change-block-menu-trigger', () => ({
|
||||
ChangeBlockMenuTrigger: () => <div data-testid="node-actions-change-block" />,
|
||||
}))
|
||||
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
|
||||
@ -94,16 +88,10 @@ const renderComponent = (
|
||||
|
||||
describe('NodeActionsDropdown', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
@ -121,18 +109,13 @@ describe('NodeActionsDropdown', () => {
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: false,
|
||||
} as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
})
|
||||
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
|
||||
})
|
||||
|
||||
it('should open the dropdown and trigger single-run actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
renderComponent(true, onOpenChange)
|
||||
const { store } = renderComponent(true, onOpenChange)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
@ -143,11 +126,8 @@ describe('NodeActionsDropdown', () => {
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
|
||||
id: 'node-1',
|
||||
data: { _isSingleRun: true },
|
||||
})
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(store.getState().initShowLastRunTab).toBe(true)
|
||||
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'run' })
|
||||
})
|
||||
|
||||
it('should hide the help link when showHelpLink is false', async () => {
|
||||
|
||||
@ -21,6 +21,9 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) {
|
||||
const hasRunGroup = model.canRun || model.canChangeBlock
|
||||
const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
|
||||
const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
|
||||
const singleRunActionLabel = model.isSingleRunning
|
||||
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
|
||||
: t('panel.runThisStep', { ns: 'workflow' })
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,7 +31,7 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) {
|
||||
<ContextMenuGroup>
|
||||
{model.canRun && (
|
||||
<ContextMenuItem onClick={model.handleRun}>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
{singleRunActionLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{model.canChangeBlock && (
|
||||
|
||||
@ -21,6 +21,9 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) {
|
||||
const hasRunGroup = model.canRun || model.canChangeBlock
|
||||
const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
|
||||
const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
|
||||
const singleRunActionLabel = model.isSingleRunning
|
||||
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
|
||||
: t('panel.runThisStep', { ns: 'workflow' })
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,7 +31,7 @@ export function NodeActionsDropdownContent(props: NodeActionsMenuProps) {
|
||||
<DropdownMenuGroup>
|
||||
{model.canRun && (
|
||||
<DropdownMenuItem onClick={model.handleRun}>
|
||||
{t('panel.runThisStep', { ns: 'workflow' })}
|
||||
{singleRunActionLabel}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{model.canChangeBlock && (
|
||||
|
||||
@ -3,13 +3,12 @@ import { useCallback, useMemo } from 'react'
|
||||
import { useEdges } from 'reactflow'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { canRunBySingle } from '@/app/components/workflow/utils'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import { canFindTool } from '@/utils'
|
||||
@ -34,14 +33,14 @@ export function useNodeActionsMenuModel({
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const nodeMetaData = useNodeMetaData({ id, data } as Node)
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
|
||||
const isChildNode = !!(data.isInIteration || data.isInLoop)
|
||||
const canRun = canRunBySingle(data.type, isChildNode)
|
||||
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
|
||||
const canChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly
|
||||
const sourceHandle = useMemo(() => {
|
||||
return edges.find(edge => edge.target === id)?.sourceHandle || 'source'
|
||||
@ -60,11 +59,15 @@ export function useNodeActionsMenuModel({
|
||||
}, [data.provider_id, data.provider_type, data.type, workflowTools])
|
||||
|
||||
const handleRun = useCallback(() => {
|
||||
const store = workflowStore.getState()
|
||||
store.setInitShowLastRunTab(true)
|
||||
store.setPendingSingleRun({
|
||||
nodeId: id,
|
||||
action: isSingleRunning ? 'stop' : 'run',
|
||||
})
|
||||
handleNodeSelect(id)
|
||||
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
|
||||
handleSyncWorkflowDraft(true)
|
||||
onClose()
|
||||
}, [handleNodeDataUpdate, handleNodeSelect, handleSyncWorkflowDraft, id, onClose])
|
||||
}, [handleNodeSelect, id, isSingleRunning, onClose, workflowStore])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
onClose()
|
||||
@ -96,6 +99,7 @@ export function useNodeActionsMenuModel({
|
||||
helpLinkUri: showHelpLink ? nodeMetaData.helpLinkUri : undefined,
|
||||
id,
|
||||
isSingleton: nodeMetaData.isSingleton,
|
||||
isSingleRunning,
|
||||
isUndeletable: nodeMetaData.isUndeletable,
|
||||
nodesReadOnly,
|
||||
sourceHandle,
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { renderWorkflowHook } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { FlowType } from '@/types/common'
|
||||
import useLastRun from '../use-last-run'
|
||||
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockShowSingleRun = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
|
||||
useWorkflowRunValidation: () => ({
|
||||
warningNodes: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({
|
||||
default: () => ({
|
||||
conversationVars: [],
|
||||
systemVars: [],
|
||||
hasSetInspectVar: vi.fn(() => false),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-one-step-run', () => ({
|
||||
default: () => ({
|
||||
hideSingleRun: vi.fn(),
|
||||
handleRun: mockHandleRun,
|
||||
getInputVars: vi.fn(() => []),
|
||||
toVarInputs: vi.fn(() => []),
|
||||
varSelectorsToVarInputs: vi.fn(() => []),
|
||||
runInputData: {},
|
||||
runInputDataRef: { current: {} },
|
||||
setRunInputData: vi.fn(),
|
||||
showSingleRun: mockShowSingleRun,
|
||||
runResult: {},
|
||||
iterationRunResult: [],
|
||||
loopRunResult: [],
|
||||
setNodeRunning: vi.fn(),
|
||||
checkValid: vi.fn(() => ({ isValid: true })),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidLastRun: () => vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useLastRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('syncs the draft before opening a custom single-run form', () => {
|
||||
const { result } = renderWorkflowHook(() => useLastRun({
|
||||
id: 'data-source-node',
|
||||
flowId: 'flow-id',
|
||||
flowType: FlowType.appFlow,
|
||||
data: {
|
||||
type: BlockEnum.DataSource,
|
||||
title: 'Data Source',
|
||||
desc: '',
|
||||
},
|
||||
defaultRunInputData: {},
|
||||
isPaused: false,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSingleRun()
|
||||
})
|
||||
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(mockShowSingleRun).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -235,7 +235,7 @@ const useLastRun = <T>({
|
||||
setTabType(TabType.lastRun)
|
||||
|
||||
setInitShowLastRunTab(false)
|
||||
}, [initShowLastRunTab])
|
||||
}, [initShowLastRunTab, setInitShowLastRunTab])
|
||||
const invalidLastRun = useInvalidLastRun(flowType, flowId, id)
|
||||
|
||||
const handleRunWithParams = async (data: Record<string, any>) => {
|
||||
@ -338,6 +338,11 @@ const useLastRun = <T>({
|
||||
hideSingleRun()
|
||||
}
|
||||
|
||||
const showSingleRunWithDraftSync = () => {
|
||||
handleSyncWorkflowDraft(true)
|
||||
showSingleRun()
|
||||
}
|
||||
|
||||
const handleSingleRun = () => {
|
||||
if (blockIfChecklistFailed())
|
||||
return
|
||||
@ -347,7 +352,7 @@ const useLastRun = <T>({
|
||||
if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule)
|
||||
setShowVariableInspectPanel(true)
|
||||
if (isCustomRunNode || isHumanInputNode) {
|
||||
showSingleRun()
|
||||
showSingleRunWithDraftSync()
|
||||
return
|
||||
}
|
||||
const vars = singleRunParams?.getDependentVars?.()
|
||||
@ -361,7 +366,7 @@ const useLastRun = <T>({
|
||||
})
|
||||
}
|
||||
else {
|
||||
showSingleRun()
|
||||
showSingleRunWithDraftSync()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user