fix(web): unify workflow node single-run actions (#37262)

This commit is contained in:
yyh 2026-06-10 14:34:18 +08:00 committed by GitHub
parent d849d60822
commit 8430255931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 134 additions and 58 deletions

View File

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

View File

@ -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 () => {

View File

@ -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 && (

View File

@ -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 && (

View File

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

View File

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

View File

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