From 7129de98cd1653c9fbd6c2dbd49eaaa8ede689f8 Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:31:22 +0800 Subject: [PATCH] feat: implement workflow onboarding modal system (#24551) --- .../workflow-onboarding-integration.test.tsx | 572 ++++++++++++++++++ web/app/components/base/modal/index.tsx | 9 +- .../components/workflow-children.tsx | 62 ++ .../workflow-onboarding-modal/index.tsx | 97 +++ .../start-node-option.tsx | 53 ++ .../start-node-selection-panel.tsx | 80 +++ .../workflow-app/hooks/use-auto-onboarding.ts | 63 ++ .../workflow-app/hooks/use-workflow-init.ts | 11 +- .../store/workflow/workflow-slice.ts | 12 + .../workflow/block-selector/index.tsx | 9 +- .../workflow/block-selector/tabs.tsx | 4 +- .../nodes/_base/components/node-handle.tsx | 2 +- web/i18n/en-US/workflow.ts | 11 + web/i18n/ja-JP/workflow.ts | 11 + web/i18n/zh-Hans/workflow.ts | 11 + 15 files changed, 998 insertions(+), 9 deletions(-) create mode 100644 web/__tests__/workflow-onboarding-integration.test.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx create mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx create mode 100644 web/app/components/workflow-app/hooks/use-auto-onboarding.ts diff --git a/web/__tests__/workflow-onboarding-integration.test.tsx b/web/__tests__/workflow-onboarding-integration.test.tsx new file mode 100644 index 0000000000..577bc20db1 --- /dev/null +++ b/web/__tests__/workflow-onboarding-integration.test.tsx @@ -0,0 +1,572 @@ +import { BlockEnum } from '@/app/components/workflow/types' +import { useWorkflowStore } from '@/app/components/workflow/store' + +// Mock zustand store +jest.mock('@/app/components/workflow/store') + +// Mock ReactFlow store +const mockGetNodes = jest.fn() +jest.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + }), + }), +})) + +describe('Workflow Onboarding Integration Logic', () => { + const mockSetShowOnboarding = jest.fn() + const mockSetHasSelectedStartNode = jest.fn() + const mockSetHasShownOnboarding = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + // Mock store implementation + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + setShowOnboarding: mockSetShowOnboarding, + hasSelectedStartNode: false, + setHasSelectedStartNode: mockSetHasSelectedStartNode, + hasShownOnboarding: false, + setHasShownOnboarding: mockSetHasShownOnboarding, + notInitialWorkflow: false, + }) + }) + + describe('Onboarding State Management', () => { + it('should initialize onboarding state correctly', () => { + const store = useWorkflowStore() + + expect(store.showOnboarding).toBe(false) + expect(store.hasSelectedStartNode).toBe(false) + expect(store.hasShownOnboarding).toBe(false) + }) + + it('should update onboarding visibility', () => { + const store = useWorkflowStore() + + store.setShowOnboarding(true) + expect(mockSetShowOnboarding).toHaveBeenCalledWith(true) + + store.setShowOnboarding(false) + expect(mockSetShowOnboarding).toHaveBeenCalledWith(false) + }) + + it('should track node selection state', () => { + const store = useWorkflowStore() + + store.setHasSelectedStartNode(true) + expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true) + }) + + it('should track onboarding show state', () => { + const store = useWorkflowStore() + + store.setHasShownOnboarding(true) + expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true) + }) + }) + + describe('Node Validation Logic', () => { + /** + * Test the critical fix in use-nodes-sync-draft.ts + * This ensures trigger nodes are recognized as valid start nodes + */ + it('should validate Start node as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.Start }, + id: 'start-1', + } + + // Simulate the validation logic from use-nodes-sync-draft.ts + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should validate TriggerSchedule as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.TriggerSchedule }, + id: 'trigger-schedule-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should validate TriggerWebhook as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.TriggerWebhook }, + id: 'trigger-webhook-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should validate TriggerPlugin as valid start node', () => { + const mockNode = { + data: { type: BlockEnum.TriggerPlugin }, + id: 'trigger-plugin-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(true) + }) + + it('should reject non-trigger nodes as invalid start nodes', () => { + const mockNode = { + data: { type: BlockEnum.LLM }, + id: 'llm-1', + } + + const isValidStartNode = mockNode.data.type === BlockEnum.Start + || mockNode.data.type === BlockEnum.TriggerSchedule + || mockNode.data.type === BlockEnum.TriggerWebhook + || mockNode.data.type === BlockEnum.TriggerPlugin + + expect(isValidStartNode).toBe(false) + }) + + it('should handle array of nodes with mixed types', () => { + const mockNodes = [ + { data: { type: BlockEnum.LLM }, id: 'llm-1' }, + { data: { type: BlockEnum.TriggerWebhook }, id: 'webhook-1' }, + { data: { type: BlockEnum.Answer }, id: 'answer-1' }, + ] + + // Simulate hasStartNode logic from use-nodes-sync-draft.ts + const hasStartNode = mockNodes.find(node => + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.TriggerSchedule + || node.data.type === BlockEnum.TriggerWebhook + || node.data.type === BlockEnum.TriggerPlugin, + ) + + expect(hasStartNode).toBeTruthy() + expect(hasStartNode?.id).toBe('webhook-1') + }) + + it('should return undefined when no valid start nodes exist', () => { + const mockNodes = [ + { data: { type: BlockEnum.LLM }, id: 'llm-1' }, + { data: { type: BlockEnum.Answer }, id: 'answer-1' }, + ] + + const hasStartNode = mockNodes.find(node => + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.TriggerSchedule + || node.data.type === BlockEnum.TriggerWebhook + || node.data.type === BlockEnum.TriggerPlugin, + ) + + expect(hasStartNode).toBeUndefined() + }) + }) + + describe('Auto-expand Logic for Node Handles', () => { + /** + * Test the auto-expand logic from node-handle.tsx + * This ensures all trigger types auto-expand the block selector + */ + it('should auto-expand for Start node in new workflow', () => { + const notInitialWorkflow = true + const nodeType = BlockEnum.Start + const isChatMode = false + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should auto-expand for TriggerSchedule in new workflow', () => { + const notInitialWorkflow = true + const nodeType = BlockEnum.TriggerSchedule + const isChatMode = false + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should auto-expand for TriggerWebhook in new workflow', () => { + const notInitialWorkflow = true + const nodeType = BlockEnum.TriggerWebhook + const isChatMode = false + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should auto-expand for TriggerPlugin in new workflow', () => { + const notInitialWorkflow = true + const nodeType = BlockEnum.TriggerPlugin + const isChatMode = false + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(true) + }) + + it('should not auto-expand for non-trigger nodes', () => { + const notInitialWorkflow = true + const nodeType = BlockEnum.LLM + const isChatMode = false + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(false) + }) + + it('should not auto-expand in chat mode', () => { + const notInitialWorkflow = true + const nodeType = BlockEnum.Start + const isChatMode = true + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(false) + }) + + it('should not auto-expand for existing workflows', () => { + const notInitialWorkflow = false + const nodeType = BlockEnum.Start + const isChatMode = false + + const shouldAutoExpand = notInitialWorkflow && ( + nodeType === BlockEnum.Start + || nodeType === BlockEnum.TriggerSchedule + || nodeType === BlockEnum.TriggerWebhook + || nodeType === BlockEnum.TriggerPlugin + ) && !isChatMode + + expect(shouldAutoExpand).toBe(false) + }) + }) + + describe('Node Creation Without Auto-selection', () => { + /** + * Test that nodes are created without the 'selected: true' property + * This prevents auto-opening the properties panel + */ + it('should create Start node without auto-selection', () => { + const nodeData = { type: BlockEnum.Start, title: 'Start' } + + // Simulate node creation logic from workflow-children.tsx + const createdNodeData = { + ...nodeData, + // Note: 'selected: true' should NOT be added + } + + expect(createdNodeData.selected).toBeUndefined() + expect(createdNodeData.type).toBe(BlockEnum.Start) + }) + + it('should create TriggerWebhook node without auto-selection', () => { + const nodeData = { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' } + const toolConfig = { webhook_url: 'https://example.com/webhook' } + + const createdNodeData = { + ...nodeData, + ...toolConfig, + // Note: 'selected: true' should NOT be added + } + + expect(createdNodeData.selected).toBeUndefined() + expect(createdNodeData.type).toBe(BlockEnum.TriggerWebhook) + expect(createdNodeData.webhook_url).toBe('https://example.com/webhook') + }) + + it('should preserve other node properties while avoiding auto-selection', () => { + const nodeData = { + type: BlockEnum.TriggerSchedule, + title: 'Schedule Trigger', + config: { interval: '1h' }, + } + + const createdNodeData = { + ...nodeData, + } + + expect(createdNodeData.selected).toBeUndefined() + expect(createdNodeData.type).toBe(BlockEnum.TriggerSchedule) + expect(createdNodeData.title).toBe('Schedule Trigger') + expect(createdNodeData.config).toEqual({ interval: '1h' }) + }) + }) + + describe('Workflow Initialization Logic', () => { + /** + * Test the initialization logic from use-workflow-init.ts + * This ensures onboarding is triggered correctly for new workflows + */ + it('should trigger onboarding for new workflow when draft does not exist', () => { + // Simulate the error handling logic from use-workflow-init.ts + const error = { + json: jest.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }), + bodyUsed: false, + } + + const mockWorkflowStore = { + setState: jest.fn(), + } + + // Simulate error handling + if (error && error.json && !error.bodyUsed) { + error.json().then((err: any) => { + if (err.code === 'draft_workflow_not_exist') { + mockWorkflowStore.setState({ + notInitialWorkflow: true, + showOnboarding: true, + }) + } + }) + } + + return error.json().then(() => { + expect(mockWorkflowStore.setState).toHaveBeenCalledWith({ + notInitialWorkflow: true, + showOnboarding: true, + }) + }) + }) + + it('should not trigger onboarding for existing workflows', () => { + // Simulate successful draft fetch + const mockWorkflowStore = { + setState: jest.fn(), + } + + // Normal initialization path should not set showOnboarding: true + mockWorkflowStore.setState({ + environmentVariables: [], + conversationVariables: [], + }) + + expect(mockWorkflowStore.setState).not.toHaveBeenCalledWith( + expect.objectContaining({ showOnboarding: true }), + ) + }) + + it('should create empty draft with proper structure', () => { + const mockSyncWorkflowDraft = jest.fn() + const appId = 'test-app-id' + + // Simulate the syncWorkflowDraft call from use-workflow-init.ts + const draftParams = { + url: `/apps/${appId}/workflows/draft`, + params: { + graph: { + nodes: [], // Empty nodes initially + edges: [], + }, + features: { + retriever_resource: { enabled: true }, + }, + environment_variables: [], + conversation_variables: [], + }, + } + + mockSyncWorkflowDraft(draftParams) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({ + url: `/apps/${appId}/workflows/draft`, + params: { + graph: { + nodes: [], + edges: [], + }, + features: { + retriever_resource: { enabled: true }, + }, + environment_variables: [], + conversation_variables: [], + }, + }) + }) + }) + + describe('Auto-Detection for Empty Canvas', () => { + beforeEach(() => { + mockGetNodes.mockClear() + }) + + it('should detect empty canvas and trigger onboarding', () => { + // Mock empty canvas + mockGetNodes.mockReturnValue([]) + + // Mock store with proper state for auto-detection + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + getState: () => ({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + }), + }) + + // Simulate empty canvas check logic + const nodes = mockGetNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data?.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + expect(isEmpty).toBe(true) + expect(nodes.length).toBe(0) + }) + + it('should detect canvas with non-start nodes as empty', () => { + // Mock canvas with non-start nodes + mockGetNodes.mockReturnValue([ + { id: '1', data: { type: BlockEnum.LLM } }, + { id: '2', data: { type: BlockEnum.Code } }, + ]) + + const nodes = mockGetNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + expect(isEmpty).toBe(true) + expect(hasStartNode).toBe(false) + }) + + it('should not detect canvas with start nodes as empty', () => { + // Mock canvas with start node + mockGetNodes.mockReturnValue([ + { id: '1', data: { type: BlockEnum.Start } }, + ]) + + const nodes = mockGetNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + expect(isEmpty).toBe(false) + expect(hasStartNode).toBe(true) + }) + + it('should not trigger onboarding if already shown in session', () => { + // Mock empty canvas + mockGetNodes.mockReturnValue([]) + + // Mock store with hasShownOnboarding = true + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: true, // Already shown in this session + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + getState: () => ({ + showOnboarding: false, + hasShownOnboarding: true, + notInitialWorkflow: false, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + }), + }) + + // Simulate the check logic with hasShownOnboarding = true + const store = useWorkflowStore() + const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow + + expect(shouldTrigger).toBe(false) + }) + + it('should not trigger onboarding during initial workflow creation', () => { + // Mock empty canvas + mockGetNodes.mockReturnValue([]) + + // Mock store with notInitialWorkflow = true (initial creation) + ;(useWorkflowStore as jest.Mock).mockReturnValue({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: true, // Initial workflow creation + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + getState: () => ({ + showOnboarding: false, + hasShownOnboarding: false, + notInitialWorkflow: true, + setShowOnboarding: mockSetShowOnboarding, + setHasShownOnboarding: mockSetHasShownOnboarding, + }), + }) + + // Simulate the check logic with notInitialWorkflow = true + const store = useWorkflowStore() + const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow + + expect(shouldTrigger).toBe(false) + }) + }) +}) diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx index 426953261e..018a4ccace 100644 --- a/web/app/components/base/modal/index.tsx +++ b/web/app/components/base/modal/index.tsx @@ -16,6 +16,8 @@ type IModal = { closable?: boolean overflowVisible?: boolean highPriority?: boolean // For modals that need to appear above dropdowns + overlayOpacity?: boolean // For semi-transparent overlay instead of default + clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal } export default function Modal({ @@ -29,13 +31,16 @@ export default function Modal({ closable = false, overflowVisible = false, highPriority = false, + overlayOpacity = false, + clickOutsideNotClose = false, }: IModal) { return ( - +
import('@/app/components/workflow/update-dsl-modal'), { ssr: false, }) const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), { ssr: false, }) +const WorkflowOnboardingModal = dynamic(() => import('./workflow-onboarding-modal'), { + ssr: false, +}) const WorkflowChildren = () => { const { eventEmitter } = useEventEmitterContextContext() const [secretEnvList, setSecretEnvList] = useState([]) const showImportDSLModal = useStore(s => s.showImportDSLModal) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) + const showOnboarding = useStore(s => s.showOnboarding) + const setShowOnboarding = useStore(s => s.setShowOnboarding) + const setHasSelectedStartNode = useStore(s => s.setHasSelectedStartNode) + const reactFlowStore = useStoreApi() + const nodesInitialData = useNodesInitialData() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { handleOnboardingClose } = useAutoOnboarding() const { handlePaneContextmenuCancel, } = usePanelInteractions() @@ -39,9 +59,51 @@ const WorkflowChildren = () => { setSecretEnvList(v.payload.data as EnvironmentVariable[]) }) + const handleCloseOnboarding = useCallback(() => { + handleOnboardingClose() + }, [handleOnboardingClose]) + + const handleSelectStartNode = useCallback((nodeType: BlockEnum, toolConfig?: ToolDefaultValue) => { + const nodeData = nodeType === BlockEnum.Start + ? nodesInitialData.start + : { ...nodesInitialData[nodeType], ...toolConfig } + + const { newNode } = generateNewNode({ + data: { + ...nodeData, + }, + position: START_INITIAL_POSITION, + }) + + const { setNodes, setEdges } = reactFlowStore.getState() + setNodes([newNode]) + setEdges([]) + + setShowOnboarding(false) + setHasSelectedStartNode(true) + + handleSyncWorkflowDraft(true, false, { + onSuccess: () => { + console.log('Node successfully saved to draft') + }, + onError: () => { + console.error('Failed to save node to draft') + }, + }) + }, [nodesInitialData, setShowOnboarding, setHasSelectedStartNode, reactFlowStore, handleSyncWorkflowDraft]) + return ( <> + { + showOnboarding && ( + + ) + } { showImportDSLModal && ( void + onSelectStartNode: (nodeType: BlockEnum, toolConfig?: ToolDefaultValue) => void +} + +const WorkflowOnboardingModal: FC = ({ + isShow, + onClose, + onSelectStartNode, +}) => { + const { t } = useTranslation() + + const handleSelectUserInput = useCallback(() => { + onSelectStartNode(BlockEnum.Start) + onClose() // Close modal after selection + }, [onSelectStartNode, onClose]) + + const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: ToolDefaultValue) => { + onSelectStartNode(nodeType, toolConfig) + onClose() // Close modal after selection + }, [onSelectStartNode, onClose]) + + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isShow) + onClose() + } + document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [isShow, onClose]) + + return ( + <> + +
+ {/* Header */} +
+

+ {t('workflow.onboarding.title')} +

+
+ {t('workflow.onboarding.description')}{' '} + about start node. +
+
+ + {/* Content */} + +
+
+ + {/* ESC tip below modal */} + {isShow && ( +
+ Press + + esc + + to dismiss +
+ )} + + ) +} + +export default WorkflowOnboardingModal diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx new file mode 100644 index 0000000000..e28de39fdd --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx @@ -0,0 +1,53 @@ +'use client' +import type { FC, ReactNode } from 'react' +import cn from '@/utils/classnames' + +type StartNodeOptionProps = { + icon: ReactNode + title: string + subtitle?: string + description: string + onClick: () => void +} + +const StartNodeOption: FC = ({ + icon, + title, + subtitle, + description, + onClick, +}) => { + return ( +
+ {/* Icon */} +
+ {icon} +
+ + {/* Text content */} +
+
+

+ {title} + {subtitle && ( + {subtitle} + )} +

+
+ +
+

+ {description} +

+
+
+
+ ) +} + +export default StartNodeOption diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx new file mode 100644 index 0000000000..d39e888a85 --- /dev/null +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx @@ -0,0 +1,80 @@ +'use client' +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import StartNodeOption from './start-node-option' +import NodeSelector from '@/app/components/workflow/block-selector' +import { Home } from '@/app/components/base/icons/src/vender/workflow' +import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' +import { BlockEnum } from '@/app/components/workflow/types' +import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types' +import { TabsEnum } from '@/app/components/workflow/block-selector/types' + +type StartNodeSelectionPanelProps = { + onSelectUserInput: () => void + onSelectTrigger: (nodeType: BlockEnum, toolConfig?: ToolDefaultValue) => void +} + +const StartNodeSelectionPanel: FC = ({ + onSelectUserInput, + onSelectTrigger, +}) => { + const { t } = useTranslation() + const [showTriggerSelector, setShowTriggerSelector] = useState(false) + + const handleTriggerClick = useCallback(() => { + setShowTriggerSelector(true) + }, []) + + const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: ToolDefaultValue) => { + setShowTriggerSelector(false) + onSelectTrigger(nodeType, toolConfig) + }, [onSelectTrigger]) + + return ( +
+ + +
+ } + title={t('workflow.onboarding.userInputFull')} + description={t('workflow.onboarding.userInputDescription')} + onClick={onSelectUserInput} + /> + + ( + + +
+ } + title={t('workflow.onboarding.trigger')} + description={t('workflow.onboarding.triggerDescription')} + onClick={handleTriggerClick} + /> + )} + popupClassName="z-[1200]" + /> + + ) +} + +export default StartNodeSelectionPanel diff --git a/web/app/components/workflow-app/hooks/use-auto-onboarding.ts b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts new file mode 100644 index 0000000000..f8d2df35ef --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-auto-onboarding.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from 'react' +import { useStoreApi } from 'reactflow' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { BlockEnum } from '@/app/components/workflow/types' + +export const useAutoOnboarding = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + const checkAndShowOnboarding = useCallback(() => { + const { getNodes } = store.getState() + const { + showOnboarding, + hasShownOnboarding, + notInitialWorkflow, + setShowOnboarding, + setHasShownOnboarding, + } = workflowStore.getState() + + // Skip if already showing onboarding or it's the initial workflow creation + if (showOnboarding || notInitialWorkflow) + return + + const nodes = getNodes() + const startNodeTypes = [ + BlockEnum.Start, + BlockEnum.TriggerSchedule, + BlockEnum.TriggerWebhook, + BlockEnum.TriggerPlugin, + ] + + // Check if canvas is empty (no nodes or no start nodes) + const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type)) + const isEmpty = nodes.length === 0 || !hasStartNode + + // Show onboarding if canvas is empty and we haven't shown it before in this session + if (isEmpty && !hasShownOnboarding) { + setShowOnboarding(true) + setHasShownOnboarding(true) + } + }, [store, workflowStore]) + + const handleOnboardingClose = useCallback(() => { + const { setShowOnboarding, setHasShownOnboarding } = workflowStore.getState() + setShowOnboarding(false) + setHasShownOnboarding(true) + }, [workflowStore]) + + // Check on mount and when nodes change + useEffect(() => { + // Small delay to ensure the workflow data is loaded + const timer = setTimeout(() => { + checkAndShowOnboarding() + }, 500) + + return () => clearTimeout(timer) + }, [checkAndShowOnboarding]) + + return { + checkAndShowOnboarding, + handleOnboardingClose, + } +} diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 6d16dc5c44..e1b29a7637 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -57,13 +57,17 @@ export const useWorkflowInit = () => { if (error && error.json && !error.bodyUsed && appDetail) { error.json().then((err: any) => { if (err.code === 'draft_workflow_not_exist') { - workflowStore.setState({ notInitialWorkflow: true }) + workflowStore.setState({ + notInitialWorkflow: true, + showOnboarding: true, + hasShownOnboarding: false, + }) syncWorkflowDraft({ url: `/apps/${appDetail.id}/workflows/draft`, params: { graph: { - nodes: nodesTemplate, - edges: edgesTemplate, + nodes: [], + edges: [], }, features: { retriever_resource: { enabled: true }, @@ -83,7 +87,6 @@ export const useWorkflowInit = () => { useEffect(() => { handleGetInitialWorkflowData() - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const handleFetchPreloadData = useCallback(async () => { diff --git a/web/app/components/workflow-app/store/workflow/workflow-slice.ts b/web/app/components/workflow-app/store/workflow/workflow-slice.ts index 77626e52b1..538530d0f5 100644 --- a/web/app/components/workflow-app/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow-app/store/workflow/workflow-slice.ts @@ -6,6 +6,12 @@ export type WorkflowSliceShape = { setNotInitialWorkflow: (notInitialWorkflow: boolean) => void nodesDefaultConfigs: Record setNodesDefaultConfigs: (nodesDefaultConfigs: Record) => void + showOnboarding: boolean + setShowOnboarding: (showOnboarding: boolean) => void + hasSelectedStartNode: boolean + setHasSelectedStartNode: (hasSelectedStartNode: boolean) => void + hasShownOnboarding: boolean + setHasShownOnboarding: (hasShownOnboarding: boolean) => void } export type CreateWorkflowSlice = StateCreator @@ -15,4 +21,10 @@ export const createWorkflowSlice: StateCreator = set => ({ setNotInitialWorkflow: notInitialWorkflow => set(() => ({ notInitialWorkflow })), nodesDefaultConfigs: {}, setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })), + showOnboarding: false, + setShowOnboarding: showOnboarding => set(() => ({ showOnboarding })), + hasSelectedStartNode: false, + setHasSelectedStartNode: hasSelectedStartNode => set(() => ({ hasSelectedStartNode })), + hasShownOnboarding: false, + setHasShownOnboarding: hasShownOnboarding => set(() => ({ hasShownOnboarding })), }) diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index b3123ecf1a..f56d922d23 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -44,6 +44,8 @@ type NodeSelectorProps = { disabled?: boolean noBlocks?: boolean showStartTab?: boolean + defaultActiveTab?: TabsEnum + forceShowStartContent?: boolean } const NodeSelector: FC = ({ open: openFromProps, @@ -61,6 +63,8 @@ const NodeSelector: FC = ({ disabled, noBlocks = false, showStartTab = false, + defaultActiveTab, + forceShowStartContent = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -87,7 +91,9 @@ const NodeSelector: FC = ({ onSelect(type, toolDefaultValue) }, [handleOpenChange, onSelect]) - const [activeTab, setActiveTab] = useState(noBlocks ? TabsEnum.Tools : TabsEnum.Blocks) + const [activeTab, setActiveTab] = useState( + defaultActiveTab || (noBlocks ? TabsEnum.Tools : TabsEnum.Blocks), + ) const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => { setActiveTab(newActiveTab) }, []) @@ -167,6 +173,7 @@ const NodeSelector: FC = ({ availableBlocksTypes={availableBlocksTypes} noBlocks={noBlocks} showStartTab={showStartTab} + forceShowStartContent={forceShowStartContent} /> diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 9ae7731bb7..3d628688c4 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -20,6 +20,7 @@ export type TabsProps = { filterElem: React.ReactNode noBlocks?: boolean showStartTab?: boolean + forceShowStartContent?: boolean // Force show Start content even when noBlocks=true } const Tabs: FC = ({ activeTab, @@ -31,6 +32,7 @@ const Tabs: FC = ({ filterElem, noBlocks, showStartTab = false, + forceShowStartContent = false, }) => { const tabs = useTabs(showStartTab) const { data: buildInTools } = useAllBuiltInTools() @@ -64,7 +66,7 @@ const Tabs: FC = ({ } {filterElem} { - activeTab === TabsEnum.Start && !noBlocks && ( + activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
{ - if (notInitialWorkflow && data.type === BlockEnum.Start && !isChatMode) + if (notInitialWorkflow && (data.type === BlockEnum.Start || data.type === BlockEnum.TriggerSchedule || data.type === BlockEnum.TriggerWebhook || data.type === BlockEnum.TriggerPlugin) && !isChatMode) setOpen(true) }, [notInitialWorkflow, data.type, isChatMode]) diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index bad6734a24..f5fee36206 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -1100,6 +1100,17 @@ const translation = { noDependents: 'No dependents', }, }, + onboarding: { + title: 'Select a start node to begin', + description: 'Different start nodes have different capabilities. Don\'t worry, you can always change them later.', + userInputFull: 'User Input (original start node)', + userInputDescription: 'Start node that allows setting user input variables, with web app, service API, MCP server, and workflow as tool capabilities.', + trigger: 'Trigger', + triggerDescription: 'Triggers can serve as the start node of a workflow, such as scheduled tasks, custom webhooks, or integrations with other apps.', + back: 'Back', + learnMore: 'Learn more', + aboutStartNode: 'about start node.', + }, } export default translation diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 731b7d1bf4..93fd90ad84 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -1098,6 +1098,17 @@ const translation = { enabled: 'スタート', disabled: '開始 • 無効', }, + onboarding: { + title: '開始するには開始ノードを選択してください', + description: '異なる開始ノードには異なる機能があります。心配しないでください、いつでも変更できます。', + userInputFull: 'ユーザー入力(元の開始ノード)', + userInputDescription: 'ユーザー入力変数の設定を可能にする開始ノードで、Webアプリ、サービスAPI、MCPサーバー、およびツールとしてのワークフロー機能を持ちます。', + trigger: 'トリガー', + triggerDescription: 'トリガーは、スケジュールされたタスク、カスタムwebhook、または他のアプリとの統合など、ワークフローの開始ノードとして機能できます。', + back: '戻る', + learnMore: '詳細を見る', + aboutStartNode: '開始ノードについて。', + }, } export default translation diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 9897cd5688..ac5c0110b1 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -1098,6 +1098,17 @@ const translation = { enabled: '开始', disabled: '开始 • 已禁用', }, + onboarding: { + title: '选择开始节点来开始', + description: '不同的开始节点具有不同的功能。不用担心,您随时可以更改它们。', + userInputFull: '用户输入(原始开始节点)', + userInputDescription: '允许设置用户输入变量的开始节点,具有Web应用程序、服务API、MCP服务器和工作流即工具功能。', + trigger: '触发器', + triggerDescription: '触发器可以作为工作流的开始节点,例如定时任务、自定义webhook或与其他应用程序的集成。', + back: '返回', + learnMore: '了解更多', + aboutStartNode: '关于开始节点。', + }, } export default translation