diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx index 49edc6aa87..4a54d759bc 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx @@ -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 } = {}) { return renderWorkflowFlowComponent( @@ -88,7 +84,7 @@ function renderDropdownContent({ @@ -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) 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) mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) - mockUseNodesSyncDraft.mockReturnValue({ - doSyncWorkflowDraft: vi.fn(), - handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), - } as ReturnType) 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, diff --git a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index 5624de9c16..6b6bdeb933 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -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() 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: () =>
, })) -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 = (data: T): UseQueryResult => ({ @@ -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) - mockUseNodesSyncDraft.mockReturnValue({ - doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), - handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), - }) mockUseAllWorkflowTools.mockReturnValue(createQueryResult([])) }) 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 () => { diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx index 0cbb1bccaa..5b5f9265f7 100644 --- a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -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) { {model.canRun && ( - {t('panel.runThisStep', { ns: 'workflow' })} + {singleRunActionLabel} )} {model.canChangeBlock && ( diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx index 8d79b706f7..9511bf9626 100644 --- a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx +++ b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx @@ -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) { {model.canRun && ( - {t('panel.runThisStep', { ns: 'workflow' })} + {singleRunActionLabel} )} {model.canChangeBlock && ( diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts index 45e34e6bb2..579b3406c9 100644 --- a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -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, diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts new file mode 100644 index 0000000000..c49171c998 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts @@ -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() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 0a7263fafd..8505369adf 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -235,7 +235,7 @@ const useLastRun = ({ setTabType(TabType.lastRun) setInitShowLastRunTab(false) - }, [initShowLastRunTab]) + }, [initShowLastRunTab, setInitShowLastRunTab]) const invalidLastRun = useInvalidLastRun(flowType, flowId, id) const handleRunWithParams = async (data: Record) => { @@ -338,6 +338,11 @@ const useLastRun = ({ hideSingleRun() } + const showSingleRunWithDraftSync = () => { + handleSyncWorkflowDraft(true) + showSingleRun() + } + const handleSingleRun = () => { if (blockIfChecklistFailed()) return @@ -347,7 +352,7 @@ const useLastRun = ({ 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 = ({ }) } else { - showSingleRun() + showSingleRunWithDraftSync() } }