feat: replace mock data with dynamic workflow options in test run dropdown (#24320)

This commit is contained in:
lyzno1 2025-08-22 16:36:09 +08:00 committed by GitHub
parent 833c902b2b
commit 392e3530bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 792 additions and 98 deletions

View File

@ -0,0 +1,487 @@
/**
* useDynamicTestRunOptions Hook Test
*
* Tests for the dynamic test run options generation hook that replaces mock data
* with real workflow node data for the test run dropdown.
*/
import { renderHook } from '@testing-library/react'
import React from 'react'
import type { Node } from 'reactflow'
import { useDynamicTestRunOptions } from '@/app/components/workflow/hooks/use-dynamic-test-run-options'
import { BlockEnum } from '@/app/components/workflow/types'
import { CollectionType } from '@/app/components/tools/types'
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'workflow.blocks.start': 'User Input',
'workflow.blocks.trigger-schedule': 'Schedule Trigger',
'workflow.blocks.trigger-webhook': 'Webhook Trigger',
'workflow.blocks.trigger-plugin': 'Plugin Trigger',
'workflow.common.runAllTriggers': 'Run all triggers',
}
return translations[key] || key
},
}),
}))
// Mock reactflow
const mockNodes: Node[] = []
jest.mock('reactflow', () => ({
useNodes: () => mockNodes,
}))
// Mock workflow store
const mockStore = {
buildInTools: [
{
id: 'builtin-tool-1',
name: 'Built-in Tool',
icon: 'builtin-icon.png',
},
],
customTools: [
{
id: 'custom-tool-1',
name: 'Custom Tool',
icon: { content: 'custom-icon', background: '#fff' },
},
],
workflowTools: [
{
id: 'workflow-tool-1',
name: 'Workflow Tool',
icon: 'workflow-icon.png',
},
],
mcpTools: [
{
id: 'mcp-tool-1',
name: 'MCP Tool',
icon: 'mcp-icon.png',
},
],
}
jest.mock('@/app/components/workflow/store', () => ({
useStore: (selector: any) => selector(mockStore),
}))
// Mock utils
jest.mock('@/utils', () => ({
canFindTool: (toolId: string, providerId: string) => toolId === providerId,
}))
// Mock useGetIcon
jest.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
__esModule: true,
default: () => ({
getIconUrl: (icon: string) => `https://example.com/icons/${icon}`,
}),
}))
// Mock workflow entry utils
jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({
getWorkflowEntryNode: (nodes: Node[]) => {
return nodes.find(node => node.data.type === BlockEnum.Start) || null
},
}))
// Mock icon components
jest.mock('@/app/components/base/icons/src/vender/workflow/Home', () => {
return function MockHome({ className }: { className: string }) {
return <div data-testid="home-icon" className={className}>Home</div>
}
})
jest.mock('@/app/components/base/icons/src/vender/workflow', () => ({
Schedule: function MockSchedule({ className }: { className: string }) {
return <div data-testid="schedule-icon" className={className}>Schedule</div>
},
WebhookLine: function MockWebhookLine({ className }: { className: string }) {
return <div data-testid="webhook-icon" className={className}>Webhook</div>
},
}))
jest.mock('@/app/components/base/app-icon', () => {
return function MockAppIcon({ icon, background, className }: any) {
return (
<div
data-testid="app-icon"
className={className}
data-icon={icon}
data-background={background}
>
AppIcon
</div>
)
}
})
describe('useDynamicTestRunOptions', () => {
beforeEach(() => {
// Clear mock nodes before each test
mockNodes.length = 0
})
describe('Empty workflow', () => {
it('should return empty options when no nodes exist', () => {
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toBeUndefined()
expect(result.current.triggers).toEqual([])
expect(result.current.runAll).toBeUndefined()
})
})
describe('Start node handling', () => {
it('should create user input option from Start node', () => {
mockNodes.push({
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Custom Start',
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toEqual({
id: 'start-1',
type: 'user_input',
name: 'Custom Start',
icon: expect.any(Object),
nodeId: 'start-1',
enabled: true,
})
})
it('should use fallback translation when Start node has no title', () => {
mockNodes.push({
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput?.name).toBe('User Input')
})
})
describe('Trigger nodes handling', () => {
it('should create schedule trigger option', () => {
mockNodes.push({
id: 'schedule-1',
type: 'trigger-schedule',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.TriggerSchedule,
title: 'Daily Schedule',
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.triggers).toHaveLength(1)
expect(result.current.triggers[0]).toEqual({
id: 'schedule-1',
type: 'schedule',
name: 'Daily Schedule',
icon: expect.any(Object),
nodeId: 'schedule-1',
enabled: true,
})
})
it('should create webhook trigger option', () => {
mockNodes.push({
id: 'webhook-1',
type: 'trigger-webhook',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.TriggerWebhook,
title: 'API Webhook',
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.triggers).toHaveLength(1)
expect(result.current.triggers[0]).toEqual({
id: 'webhook-1',
type: 'webhook',
name: 'API Webhook',
icon: expect.any(Object),
nodeId: 'webhook-1',
enabled: true,
})
})
it('should create plugin trigger option with built-in tool', () => {
mockNodes.push({
id: 'plugin-1',
type: 'trigger-plugin',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.TriggerPlugin,
title: 'Plugin Trigger',
provider_id: 'builtin-tool-1',
provider_type: CollectionType.builtIn,
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.triggers).toHaveLength(1)
expect(result.current.triggers[0]).toEqual({
id: 'plugin-1',
type: 'plugin',
name: 'Plugin Trigger',
icon: expect.any(Object),
nodeId: 'plugin-1',
enabled: true,
})
})
it('should create plugin trigger option with custom tool', () => {
mockNodes.push({
id: 'plugin-2',
type: 'trigger-plugin',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.TriggerPlugin,
title: 'Custom Plugin',
provider_id: 'custom-tool-1',
provider_type: CollectionType.custom,
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.triggers).toHaveLength(1)
expect(result.current.triggers[0].icon).toBeDefined()
})
it('should create plugin trigger option with fallback icon when tool not found', () => {
mockNodes.push({
id: 'plugin-3',
type: 'trigger-plugin',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.TriggerPlugin,
title: 'Unknown Plugin',
provider_id: 'unknown-tool',
provider_type: CollectionType.builtIn,
},
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.triggers).toHaveLength(1)
expect(result.current.triggers[0].name).toBe('Unknown Plugin')
})
})
describe('Run all triggers option', () => {
it('should create runAll option when multiple triggers exist', () => {
mockNodes.push(
{
id: 'schedule-1',
type: 'trigger-schedule',
position: { x: 0, y: 0 },
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule 1' },
},
{
id: 'webhook-1',
type: 'trigger-webhook',
position: { x: 100, y: 0 },
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook 1' },
},
)
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.runAll).toEqual({
id: 'run-all',
type: 'all',
name: 'Run all triggers',
icon: expect.any(Object),
enabled: true,
})
})
it('should not create runAll option when only one trigger exists', () => {
mockNodes.push({
id: 'schedule-1',
type: 'trigger-schedule',
position: { x: 0, y: 0 },
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule 1' },
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.runAll).toBeUndefined()
})
it('should not create runAll option when no triggers exist', () => {
mockNodes.push({
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: { type: BlockEnum.Start, title: 'Start' },
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.runAll).toBeUndefined()
})
})
describe('Complex workflow scenarios', () => {
it('should handle workflow with all node types', () => {
mockNodes.push(
{
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: { type: BlockEnum.Start, title: 'User Input' },
},
{
id: 'schedule-1',
type: 'trigger-schedule',
position: { x: 100, y: 0 },
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule Trigger' },
},
{
id: 'webhook-1',
type: 'trigger-webhook',
position: { x: 200, y: 0 },
data: { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' },
},
{
id: 'plugin-1',
type: 'trigger-plugin',
position: { x: 300, y: 0 },
data: {
type: BlockEnum.TriggerPlugin,
title: 'Plugin Trigger',
provider_id: 'builtin-tool-1',
provider_type: CollectionType.builtIn,
},
},
)
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toBeDefined()
expect(result.current.triggers).toHaveLength(3)
expect(result.current.runAll).toBeDefined()
// Verify node ID mapping for future click functionality
expect(result.current.userInput?.nodeId).toBe('start-1')
expect(result.current.triggers[0].nodeId).toBe('schedule-1')
expect(result.current.triggers[1].nodeId).toBe('webhook-1')
expect(result.current.triggers[2].nodeId).toBe('plugin-1')
})
it('should handle nodes with missing data gracefully', () => {
mockNodes.push(
{
id: 'invalid-1',
type: 'unknown',
position: { x: 0, y: 0 },
data: {}, // Missing type
},
{
id: 'start-1',
type: 'start',
position: { x: 100, y: 0 },
data: { type: BlockEnum.Start },
},
)
const { result } = renderHook(() => useDynamicTestRunOptions())
// Should only process valid nodes
expect(result.current.userInput).toBeDefined()
expect(result.current.triggers).toHaveLength(0)
})
it('should use workflow entry node as fallback when no direct Start node exists', () => {
// No Start node, but workflow entry utility finds a fallback
mockNodes.push({
id: 'fallback-1',
type: 'other',
position: { x: 0, y: 0 },
data: { type: BlockEnum.Start, title: 'Fallback Start' },
})
const { result } = renderHook(() => useDynamicTestRunOptions())
expect(result.current.userInput).toBeDefined()
expect(result.current.userInput?.nodeId).toBe('fallback-1')
})
})
describe('Node ID mapping for click functionality', () => {
it('should ensure all options have nodeId for proper click handling', () => {
mockNodes.push(
{
id: 'start-node-123',
type: 'start',
position: { x: 0, y: 0 },
data: { type: BlockEnum.Start, title: 'Start' },
},
{
id: 'trigger-node-456',
type: 'trigger-schedule',
position: { x: 100, y: 0 },
data: { type: BlockEnum.TriggerSchedule, title: 'Schedule' },
},
)
const { result } = renderHook(() => useDynamicTestRunOptions())
// Verify node ID mapping exists for click functionality
expect(result.current.userInput?.nodeId).toBe('start-node-123')
expect(result.current.triggers[0].nodeId).toBe('trigger-node-456')
// runAll doesn't need nodeId as it handles multiple nodes
expect(result.current.runAll?.nodeId).toBeUndefined()
})
})
describe('Icon rendering verification', () => {
it('should render proper icon components for each node type', () => {
mockNodes.push(
{
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: { type: BlockEnum.Start },
},
{
id: 'schedule-1',
type: 'trigger-schedule',
position: { x: 100, y: 0 },
data: { type: BlockEnum.TriggerSchedule },
},
)
const { result } = renderHook(() => useDynamicTestRunOptions())
// Icons should be React elements
expect(React.isValidElement(result.current.userInput?.icon)).toBe(true)
expect(React.isValidElement(result.current.triggers[0]?.icon)).toBe(true)
})
})
})

View File

@ -0,0 +1,8 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.34698 6.42505C5.10275 5.79268 5.67045 5.17005 6.29816 5.30916L6.42446 5.34758L13.0846 7.92049L13.1999 7.97518C13.7051 8.26089 13.7647 8.9802 13.3118 9.34432L13.207 9.41659L10.8196 10.8202L9.416 13.2076C9.08465 13.7711 8.28069 13.742 7.97459 13.2004L7.9199 13.0852L5.34698 6.42505ZM8.791 11.6392L9.73631 10.0325L9.73696 10.0318L9.7962 9.94458C9.86055 9.86164 9.94031 9.79125 10.0312 9.73755L10.0319 9.7369L11.6387 8.79159L6.99738 6.99797L8.791 11.6392Z" fill="white"/>
<path d="M2.79751 8.9257C3.05781 8.66539 3.47985 8.66547 3.74021 8.9257C4.00057 9.18604 4.00056 9.60805 3.74021 9.86841L3.03318 10.5754C2.77283 10.8356 2.35078 10.8357 2.09047 10.5754C1.83032 10.3151 1.83033 9.89305 2.09047 9.63273L2.79751 8.9257Z" fill="white"/>
<path d="M1.99998 5.66659C2.36817 5.66659 2.66665 5.96506 2.66665 6.33325C2.66665 6.70144 2.36817 6.99992 1.99998 6.99992H0.99998C0.63179 6.99992 0.333313 6.70144 0.333313 6.33325C0.333313 5.96506 0.63179 5.66659 0.99998 5.66659H1.99998Z" fill="white"/>
<path d="M9.63279 2.09106C9.8931 1.83077 10.3151 1.83086 10.5755 2.09106C10.8358 2.35142 10.8359 2.77343 10.5755 3.03377L9.86847 3.7408C9.6081 4.00098 9.18605 4.0011 8.92576 3.7408C8.66559 3.4805 8.66562 3.05841 8.92576 2.7981L9.63279 2.09106Z" fill="white"/>
<path d="M2.09113 2.09041C2.33521 1.84649 2.72126 1.83132 2.98305 2.04484L3.03383 2.09041L3.74087 2.79744L3.78644 2.84823C3.9999 3.11002 3.98476 3.49609 3.74087 3.74015C3.49682 3.9842 3.11079 3.9992 2.84894 3.78573L2.79816 3.74015L2.09113 3.03312L2.04555 2.98234C1.83199 2.72049 1.84705 2.33449 2.09113 2.09041Z" fill="white"/>
<path d="M6.33331 0.333252C6.7015 0.333252 6.99998 0.631729 6.99998 0.999919V1.99992C6.99998 2.36811 6.7015 2.66659 6.33331 2.66659C5.96512 2.66659 5.66665 2.36811 5.66665 1.99992V0.999919C5.66665 0.631729 5.96512 0.333252 6.33331 0.333252Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,73 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "14",
"height": "14",
"viewBox": "0 0 14 14",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M5.34698 6.42505C5.10275 5.79268 5.67045 5.17005 6.29816 5.30916L6.42446 5.34758L13.0846 7.92049L13.1999 7.97518C13.7051 8.26089 13.7647 8.9802 13.3118 9.34432L13.207 9.41659L10.8196 10.8202L9.416 13.2076C9.08465 13.7711 8.28069 13.742 7.97459 13.2004L7.9199 13.0852L5.34698 6.42505ZM8.791 11.6392L9.73631 10.0325L9.73696 10.0318L9.7962 9.94458C9.86055 9.86164 9.94031 9.79125 10.0312 9.73755L10.0319 9.7369L11.6387 8.79159L6.99738 6.99797L8.791 11.6392Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M2.79751 8.9257C3.05781 8.66539 3.47985 8.66547 3.74021 8.9257C4.00057 9.18604 4.00056 9.60805 3.74021 9.86841L3.03318 10.5754C2.77283 10.8356 2.35078 10.8357 2.09047 10.5754C1.83032 10.3151 1.83033 9.89305 2.09047 9.63273L2.79751 8.9257Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M1.99998 5.66659C2.36817 5.66659 2.66665 5.96506 2.66665 6.33325C2.66665 6.70144 2.36817 6.99992 1.99998 6.99992H0.99998C0.63179 6.99992 0.333313 6.70144 0.333313 6.33325C0.333313 5.96506 0.63179 5.66659 0.99998 5.66659H1.99998Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M9.63279 2.09106C9.8931 1.83077 10.3151 1.83086 10.5755 2.09106C10.8358 2.35142 10.8359 2.77343 10.5755 3.03377L9.86847 3.7408C9.6081 4.00098 9.18605 4.0011 8.92576 3.7408C8.66559 3.4805 8.66562 3.05841 8.92576 2.7981L9.63279 2.09106Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M2.09113 2.09041C2.33521 1.84649 2.72126 1.83132 2.98305 2.04484L3.03383 2.09041L3.74087 2.79744L3.78644 2.84823C3.9999 3.11002 3.98476 3.49609 3.74087 3.74015C3.49682 3.9842 3.11079 3.9992 2.84894 3.78573L2.79816 3.74015L2.09113 3.03312L2.04555 2.98234C1.83199 2.72049 1.84705 2.33449 2.09113 2.09041Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M6.33331 0.333252C6.7015 0.333252 6.99998 0.631729 6.99998 0.999919V1.99992C6.99998 2.36811 6.7015 2.66659 6.33331 2.66659C5.96512 2.66659 5.66665 2.36811 5.66665 1.99992V0.999919C5.66665 0.631729 5.96512 0.333252 6.33331 0.333252Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "RunAllTriggers"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './RunAllTriggers.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'RunAllTriggers'
export default Icon

View File

@ -19,6 +19,7 @@ export { default as LoopEnd } from './LoopEnd'
export { default as Loop } from './Loop'
export { default as ParameterExtractor } from './ParameterExtractor'
export { default as QuestionClassifier } from './QuestionClassifier'
export { default as RunAllTriggers } from './RunAllTriggers'
export { default as Schedule } from './Schedule'
export { default as TemplatingTransform } from './TemplatingTransform'
export { default as VariableX } from './VariableX'

View File

@ -36,7 +36,7 @@ const AllStartBlocks = ({
<StartBlocks
searchText={searchText}
onSelect={onSelect}
availableBlocksTypes={ENTRY_NODE_TYPES as BlockEnum[]}
availableBlocksTypes={ENTRY_NODE_TYPES as unknown as BlockEnum[]}
/>
<TriggerPluginSelector

View File

@ -3,9 +3,10 @@ import {
useCallback,
useMemo,
} from 'react'
import { useNodes } from 'reactflow'
import { useTranslation } from 'react-i18next'
import BlockIcon from '../block-icon'
import type { BlockEnum } from '../types'
import type { BlockEnum, CommonNodeType } from '../types'
import { BlockEnum as BlockEnumValues } from '../types'
import { useNodesExtraData } from '../hooks'
import { START_BLOCKS } from './constants'
@ -24,10 +25,18 @@ const StartBlocks = ({
availableBlocksTypes = [],
}: StartBlocksProps) => {
const { t } = useTranslation()
const nodes = useNodes()
const nodesExtraData = useNodesExtraData()
const filteredBlocks = useMemo(() => {
// Check if Start node already exists in workflow
const hasStartNode = nodes.some(node => (node.data as CommonNodeType)?.type === BlockEnumValues.Start)
return START_BLOCKS.filter((block) => {
// Hide User Input (Start) if it already exists in workflow
if (block.type === BlockEnumValues.Start && hasStartNode)
return false
// Filter by search text
if (!block.title.toLowerCase().includes(searchText.toLowerCase()))
return false
@ -35,7 +44,7 @@ const StartBlocks = ({
// availableBlocksTypes now contains properly filtered entry node types from parent
return availableBlocksTypes.includes(block.type)
})
}, [searchText, availableBlocksTypes])
}, [searchText, availableBlocksTypes, nodes])
const isEmpty = filteredBlocks.length === 0

View File

@ -15,8 +15,9 @@ import {
import { WorkflowRunningStatus } from '../types'
import ViewHistory from './view-history'
import Checklist from './checklist'
import TestRunDropdown, { createMockOptions } from './test-run-dropdown'
import TestRunDropdown from './test-run-dropdown'
import type { TriggerOption } from './test-run-dropdown'
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
import cn from '@/utils/classnames'
import {
StopCircle,
@ -32,14 +33,20 @@ const RunMode = memo(() => {
const { handleStopRun } = useWorkflowRun()
const workflowRunningData = useStore(s => s.workflowRunningData)
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
const dynamicOptions = useDynamicTestRunOptions()
const handleStop = () => {
handleStopRun(workflowRunningData?.task_id || '')
}
const handleTriggerSelect = (option: TriggerOption) => {
console.log('Selected trigger:', option)
handleWorkflowStartRunInWorkflow()
if (option.type === 'user_input') {
handleWorkflowStartRunInWorkflow()
}
else {
// TODO: Implement trigger-specific execution logic for schedule, webhook, plugin types
console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId)
}
}
const { eventEmitter } = useEventEmitterContextContext()
@ -64,7 +71,7 @@ const RunMode = memo(() => {
</div>
)
: (
<TestRunDropdown options={createMockOptions()} onSelect={handleTriggerSelect}>
<TestRunDropdown options={dynamicOptions} onSelect={handleTriggerSelect}>
<div
className={cn(
'flex h-7 items-center rounded-md px-2.5 text-[13px] font-medium text-components-button-secondary-accent-text',

View File

@ -1,8 +1,6 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Home from '@/app/components/base/icons/src/vender/workflow/Home'
import Google from '@/app/components/base/icons/src/public/plugins/Google'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -30,87 +28,6 @@ type TestRunDropdownProps = {
children: React.ReactNode
}
const createMockOptions = (): TestRunOptions => {
const userInput: TriggerOption = {
id: 'user-input-1',
type: 'user_input',
name: 'User Input',
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded bg-util-colors-blue-brand-blue-brand-500">
<Home className="h-4 w-4 text-text-primary-on-surface" />
</div>
),
nodeId: 'start-node-1',
enabled: true,
}
const runAll: TriggerOption = {
id: 'run-all',
type: 'all',
name: 'Run all triggers',
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded bg-util-colors-purple-purple-500">
<svg className="h-4 w-4 text-text-primary-on-surface" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
),
enabled: true,
}
const triggers: TriggerOption[] = [
{
id: 'slack-trigger-1',
type: 'plugin',
name: 'Slack Trigger',
icon: (
<div className="flex h-6 w-6 items-center justify-center">
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52-2.523c0-1.393 1.125-2.528 2.52-2.528h2.52v2.528c0 1.393-1.125 2.523-2.52 2.523zM6.313 17c0-1.393 1.125-2.528 2.52-2.528s2.52 1.135 2.52 2.528v6.315c0 1.393-1.125 2.528-2.52 2.528s-2.52-1.135-2.52-2.528V17z" fill="#e01e5a"/>
<path d="M8.835 5.042a2.528 2.528 0 0 1-2.523-2.52C6.312 1.127 7.447.002 8.835.002s2.523 1.125 2.523 2.52v2.52H8.835zM17 6.313c1.393 0 2.528 1.125 2.528 2.52s-1.135 2.52-2.528 2.52H10.685c-1.393 0-2.528-1.125-2.528-2.52s1.135-2.52 2.528-2.52H17z" fill="#36c5f0"/>
<path d="M18.958 8.835a2.528 2.528 0 0 1 2.52-2.523c1.393 0 2.528 1.125 2.528 2.523s-1.125 2.523-2.528 2.523h-2.52V8.835zM17.687 17c0-1.393-1.125-2.528-2.52-2.528s-2.52 1.135-2.52 2.528v6.315c0 1.393 1.125 2.528 2.52 2.528s2.52-1.135 2.52-2.528V17z" fill="#2eb67d"/>
<path d="M15.165 18.958a2.528 2.528 0 0 1 2.523-2.52c1.393 0 2.528 1.125 2.528 2.52s-1.125 2.523-2.528 2.523h-2.523v-2.523zM7 17.687c-1.393 0-2.528-1.125-2.528-2.52s1.135-2.sk 2.528-2.52h6.315c1.393 0 2.528 1.125 2.528 2.sk s-1.135 2.sk-2.528 2.sk H7z" fill="#ecb22e"/>
</svg>
</div>
),
nodeId: 'slack-trigger-1',
enabled: true,
},
{
id: 'zapier-trigger-1',
type: 'plugin',
name: 'Zapier Trigger',
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded bg-util-colors-orange-orange-500">
<svg className="h-4 w-4 text-text-primary-on-surface" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0L8.25 8.25H0l6.75 6.75L3 24l9-6.75L21 24l-3.75-9L24 8.25h-8.25L12 0z"/>
</svg>
</div>
),
nodeId: 'zapier-trigger-1',
enabled: true,
},
{
id: 'gmail-trigger-1',
type: 'plugin',
name: 'Gmail Sender',
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded bg-components-panel-bg">
<Google className="h-5 w-5" />
</div>
),
nodeId: 'gmail-trigger-1',
enabled: true,
},
]
return {
userInput,
triggers,
runAll: triggers.length > 1 ? runAll : undefined,
}
}
const TestRunDropdown: FC<TestRunDropdownProps> = ({
options,
onSelect,
@ -130,11 +47,13 @@ const TestRunDropdown: FC<TestRunDropdownProps> = ({
className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
onClick={() => handleSelect(option)}
>
<div className='flex items-center space-x-3'>
{option.icon}
<span>{option.name}</span>
<div className='flex min-w-0 flex-1 items-center'>
<div className='flex h-6 w-6 shrink-0 items-center justify-center'>
{option.icon}
</div>
<span className='ml-2 truncate'>{option.name}</span>
</div>
<div className='ml-auto flex h-4 w-4 items-center justify-center rounded bg-state-base-hover-alt text-xs font-medium text-text-tertiary'>
<div className='ml-2 flex h-4 w-4 shrink-0 items-center justify-center rounded bg-state-base-hover-alt text-xs font-medium text-text-tertiary'>
{numberDisplay}
</div>
</div>
@ -158,8 +77,8 @@ const TestRunDropdown: FC<TestRunDropdownProps> = ({
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
<PortalToFollowElemContent className='z-[12]'>
<div className='w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
<div className='mb-2 px-3 pt-2 text-sm font-medium text-text-primary'>
{t('workflow.common.chooseStartNodeToRun')}
</div>
@ -182,5 +101,4 @@ const TestRunDropdown: FC<TestRunDropdownProps> = ({
)
}
export { createMockOptions }
export default TestRunDropdown

View File

@ -0,0 +1,170 @@
import { useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useTranslation } from 'react-i18next'
import { BlockEnum, type CommonNodeType } from '../types'
import { getWorkflowEntryNode } from '../utils/workflow-entry'
import type { TestRunOptions, TriggerOption } from '../header/test-run-dropdown'
import Home from '@/app/components/base/icons/src/vender/workflow/Home'
import { RunAllTriggers, Schedule, WebhookLine } from '@/app/components/base/icons/src/vender/workflow'
import AppIcon from '@/app/components/base/app-icon'
import { useStore } from '../store'
import { canFindTool } from '@/utils'
import { CollectionType } from '@/app/components/tools/types'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
export const useDynamicTestRunOptions = (): TestRunOptions => {
const { t } = useTranslation()
const nodes = useNodes()
const buildInTools = useStore(s => s.buildInTools)
const customTools = useStore(s => s.customTools)
const workflowTools = useStore(s => s.workflowTools)
const mcpTools = useStore(s => s.mcpTools)
const { getIconUrl } = useGetIcon()
return useMemo(() => {
const allTriggers: TriggerOption[] = []
let userInput: TriggerOption | undefined
for (const node of nodes) {
const nodeData = node.data as CommonNodeType
if (!nodeData?.type) continue
if (nodeData.type === BlockEnum.Start) {
userInput = {
id: node.id,
type: 'user_input',
name: nodeData.title || t('workflow.blocks.start'),
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-blue-brand-blue-brand-500 text-white shadow-md">
<Home className="h-3.5 w-3.5" />
</div>
),
nodeId: node.id,
enabled: true,
}
}
else if (nodeData.type === BlockEnum.TriggerSchedule) {
allTriggers.push({
id: node.id,
type: 'schedule',
name: nodeData.title || t('workflow.blocks.trigger-schedule'),
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-violet-violet-500 text-white shadow-md">
<Schedule className="h-4.5 w-4.5" />
</div>
),
nodeId: node.id,
enabled: true,
})
}
else if (nodeData.type === BlockEnum.TriggerWebhook) {
allTriggers.push({
id: node.id,
type: 'webhook',
name: nodeData.title || t('workflow.blocks.trigger-webhook'),
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-blue-blue-500 text-white shadow-md">
<WebhookLine className="h-4.5 w-4.5" />
</div>
),
nodeId: node.id,
enabled: true,
})
}
else if (nodeData.type === BlockEnum.TriggerPlugin) {
let icon
let toolIcon: string | any
// 按照 use-workflow-search.tsx 的模式获取工具图标
if (nodeData.provider_id) {
let targetTools = workflowTools
if (nodeData.provider_type === CollectionType.builtIn)
targetTools = buildInTools
else if (nodeData.provider_type === CollectionType.custom)
targetTools = customTools
else if (nodeData.provider_type === CollectionType.mcp)
targetTools = mcpTools
toolIcon = targetTools.find(toolWithProvider => canFindTool(toolWithProvider.id, nodeData.provider_id!))?.icon
}
if (typeof toolIcon === 'string') {
const iconUrl = toolIcon.startsWith('http') ? toolIcon : getIconUrl(toolIcon)
icon = (
<div
className="bg-util-colors-white-white-500 flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 text-white shadow-md"
style={{
backgroundImage: `url(${iconUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)
}
else if (toolIcon && typeof toolIcon === 'object' && 'content' in toolIcon) {
icon = (
<AppIcon
className="!h-6 !w-6 rounded-lg border-[0.5px] border-white/2 shadow-md"
size="tiny"
icon={toolIcon.content}
background={toolIcon.background}
/>
)
}
else {
icon = (
<div className="bg-util-colors-white-white-500 flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 text-white shadow-md">
<span className="text-xs font-medium text-text-tertiary">P</span>
</div>
)
}
allTriggers.push({
id: node.id,
type: 'plugin',
name: nodeData.title || (nodeData as any).plugin_name || t('workflow.blocks.trigger-plugin'),
icon,
nodeId: node.id,
enabled: true,
})
}
}
if (!userInput) {
const startNode = getWorkflowEntryNode(nodes as any[])
if (startNode && startNode.data?.type === BlockEnum.Start) {
userInput = {
id: startNode.id,
type: 'user_input',
name: (startNode.data as CommonNodeType)?.title || t('workflow.blocks.start'),
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-blue-brand-blue-brand-500 text-white shadow-md">
<Home className="h-3.5 w-3.5" />
</div>
),
nodeId: startNode.id,
enabled: true,
}
}
}
const runAll: TriggerOption | undefined = allTriggers.length > 1 ? {
id: 'run-all',
type: 'all',
name: t('workflow.common.runAllTriggers'),
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md">
<RunAllTriggers className="h-4.5 w-4.5" />
</div>
),
enabled: true,
} : undefined
return {
userInput,
triggers: allTriggers,
runAll,
}
}, [nodes, buildInTools, customTools, workflowTools, mcpTools, getIconUrl, t])
}

View File

@ -11,7 +11,8 @@ const translation = {
publishUpdate: 'Publish Update',
run: 'Test Run',
running: 'Running',
chooseStartNodeToRun: 'Choose the entry node to run',
chooseStartNodeToRun: 'Choose the start node to run',
runAllTriggers: 'Run all triggers',
inRunMode: 'In Run Mode',
inPreview: 'In Preview',
inPreviewMode: 'In Preview Mode',