feat: implement workflow onboarding modal system (#24551)

This commit is contained in:
lyzno1 2025-08-27 13:31:22 +08:00 committed by GitHub
parent 2984dbc0df
commit 7129de98cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 998 additions and 9 deletions

View File

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

View File

@ -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 (
<Transition appear show={isShow} as={Fragment}>
<Dialog as="div" className={classNames('relative', highPriority ? 'z-[1100]' : 'z-[60]', wrapperClassName)} onClose={onClose}>
<Dialog as="div" className={classNames('relative', highPriority ? 'z-[1100]' : 'z-[60]', wrapperClassName)} onClose={clickOutsideNotClose ? noop : onClose}>
<TransitionChild>
<div className={classNames(
'fixed inset-0 bg-background-overlay',
'fixed inset-0',
overlayOpacity ? 'bg-workflow-canvas-canvas-overlay' : 'bg-background-overlay',
'duration-300 ease-in data-[closed]:opacity-0',
'data-[enter]:opacity-100',
'data-[leave]:opacity-0',

View File

@ -1,31 +1,51 @@
import {
memo,
useCallback,
useState,
} from 'react'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
import { START_INITIAL_POSITION } from '@/app/components/workflow/constants'
import { generateNewNode } from '@/app/components/workflow/utils'
import { useNodesInitialData } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import { useStoreApi } from 'reactflow'
import PluginDependency from '@/app/components/workflow/plugin-dependency'
import {
useDSL,
usePanelInteractions,
} from '@/app/components/workflow/hooks'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import WorkflowHeader from './workflow-header'
import WorkflowPanel from './workflow-panel'
import dynamic from 'next/dynamic'
import { BlockEnum } from '@/app/components/workflow/types'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import { useAutoOnboarding } from '../hooks/use-auto-onboarding'
const UpdateDSLModal = dynamic(() => 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<EnvironmentVariable[]>([])
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 (
<>
<PluginDependency />
{
showOnboarding && (
<WorkflowOnboardingModal
isShow={showOnboarding}
onClose={handleCloseOnboarding}
onSelectStartNode={handleSelectStartNode}
/>
)
}
{
showImportDSLModal && (
<UpdateDSLModal

View File

@ -0,0 +1,97 @@
'use client'
import type { FC } from 'react'
import {
useCallback,
useEffect,
} from 'react'
import { useTranslation } from 'react-i18next'
import { BlockEnum } from '@/app/components/workflow/types'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import Modal from '@/app/components/base/modal'
import StartNodeSelectionPanel from './start-node-selection-panel'
type WorkflowOnboardingModalProps = {
isShow: boolean
onClose: () => void
onSelectStartNode: (nodeType: BlockEnum, toolConfig?: ToolDefaultValue) => void
}
const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
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 (
<>
<Modal
isShow={isShow}
onClose={onClose}
className="w-[618px] max-w-[618px] rounded-2xl border border-effects-highlight bg-background-default-subtle shadow-lg"
overlayOpacity
closable
clickOutsideNotClose
>
<div className="pb-4">
{/* Header */}
<div className="mb-6">
<h3 className="title-2xl-semi-bold mb-2 text-text-primary">
{t('workflow.onboarding.title')}
</h3>
<div className="body-xs-regular leading-4 text-text-tertiary">
{t('workflow.onboarding.description')}{' '}
<button
type="button"
className="hover:text-text-accent-hover cursor-pointer text-text-accent underline"
onClick={() => {
console.log('Navigate to start node documentation')
}}
>
Learn more
</button> about start node.
</div>
</div>
{/* Content */}
<StartNodeSelectionPanel
onSelectUserInput={handleSelectUserInput}
onSelectTrigger={handleTriggerSelect}
/>
</div>
</Modal>
{/* ESC tip below modal */}
{isShow && (
<div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[160px] items-center gap-1 text-text-quaternary">
<span>Press</span>
<kbd className="system-kbd inline-flex h-4 min-w-4 items-center justify-center rounded bg-components-kbd-bg-gray px-1 text-text-tertiary">
esc
</kbd>
<span>to dismiss</span>
</div>
)}
</>
)
}
export default WorkflowOnboardingModal

View File

@ -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<StartNodeOptionProps> = ({
icon,
title,
subtitle,
description,
onClick,
}) => {
return (
<div
onClick={onClick}
className={cn(
'hover:border-components-panel-border-active flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md',
)}
>
{/* Icon */}
<div className="shrink-0">
{icon}
</div>
{/* Text content */}
<div className="flex h-[74px] flex-col gap-1 py-0.5">
<div className="h-5 leading-5">
<h3 className="system-md-semi-bold text-text-primary">
{title}
{subtitle && (
<span className="system-md-regular text-text-quaternary"> {subtitle}</span>
)}
</h3>
</div>
<div className="h-12 leading-4">
<p className="system-xs-regular text-text-tertiary">
{description}
</p>
</div>
</div>
</div>
)
}
export default StartNodeOption

View File

@ -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<StartNodeSelectionPanelProps> = ({
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 (
<div className="grid grid-cols-2 gap-4">
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<Home className="h-5 w-5 text-white" />
</div>
}
title={t('workflow.onboarding.userInputFull')}
description={t('workflow.onboarding.userInputDescription')}
onClick={onSelectUserInput}
/>
<NodeSelector
open={showTriggerSelector}
onOpenChange={setShowTriggerSelector}
onSelect={handleTriggerSelect}
placement="right"
offset={-200}
noBlocks={true}
showStartTab={true}
defaultActiveTab={TabsEnum.Start}
forceShowStartContent={true}
availableBlocksTypes={[
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]}
trigger={() => (
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
<TriggerAll className="h-5 w-5 text-white" />
</div>
}
title={t('workflow.onboarding.trigger')}
description={t('workflow.onboarding.triggerDescription')}
onClick={handleTriggerClick}
/>
)}
popupClassName="z-[1200]"
/>
</div>
)
}
export default StartNodeSelectionPanel

View File

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

View File

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

View File

@ -6,6 +6,12 @@ export type WorkflowSliceShape = {
setNotInitialWorkflow: (notInitialWorkflow: boolean) => void
nodesDefaultConfigs: Record<string, any>
setNodesDefaultConfigs: (nodesDefaultConfigs: Record<string, any>) => void
showOnboarding: boolean
setShowOnboarding: (showOnboarding: boolean) => void
hasSelectedStartNode: boolean
setHasSelectedStartNode: (hasSelectedStartNode: boolean) => void
hasShownOnboarding: boolean
setHasShownOnboarding: (hasShownOnboarding: boolean) => void
}
export type CreateWorkflowSlice = StateCreator<WorkflowSliceShape>
@ -15,4 +21,10 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = 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 })),
})

View File

@ -44,6 +44,8 @@ type NodeSelectorProps = {
disabled?: boolean
noBlocks?: boolean
showStartTab?: boolean
defaultActiveTab?: TabsEnum
forceShowStartContent?: boolean
}
const NodeSelector: FC<NodeSelectorProps> = ({
open: openFromProps,
@ -61,6 +63,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
disabled,
noBlocks = false,
showStartTab = false,
defaultActiveTab,
forceShowStartContent = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
@ -87,7 +91,9 @@ const NodeSelector: FC<NodeSelectorProps> = ({
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<NodeSelectorProps> = ({
availableBlocksTypes={availableBlocksTypes}
noBlocks={noBlocks}
showStartTab={showStartTab}
forceShowStartContent={forceShowStartContent}
/>
</div>
</PortalToFollowElemContent>

View File

@ -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<TabsProps> = ({
activeTab,
@ -31,6 +32,7 @@ const Tabs: FC<TabsProps> = ({
filterElem,
noBlocks,
showStartTab = false,
forceShowStartContent = false,
}) => {
const tabs = useTabs(showStartTab)
const { data: buildInTools } = useAllBuiltInTools()
@ -64,7 +66,7 @@ const Tabs: FC<TabsProps> = ({
}
{filterElem}
{
activeTab === TabsEnum.Start && !noBlocks && (
activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
<div className='border-t border-divider-subtle'>
<AllStartBlocks
searchText={searchText}

View File

@ -157,7 +157,7 @@ export const NodeSourceHandle = memo(({
}, [handleNodeAdd, id, handleId])
useEffect(() => {
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])

View File

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

View File

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

View File

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