feat: comprehensive trigger node system with Schedule Trigger implementation (#24039)

Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
This commit is contained in:
lyzno1 2025-08-18 09:23:16 +08:00 committed by GitHub
parent f214eeb7b1
commit 74ad21b145
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 3709 additions and 48 deletions

View File

@ -26,6 +26,8 @@ const defaultItems = [
export type Item = {
value: number | string
name: string
isGroup?: boolean
disabled?: boolean
} & Record<string, any>
export type ISelectProps = {
@ -255,38 +257,47 @@ const SimpleSelect: FC<ISelectProps> = ({
{(!disabled) && (
<ListboxOptions className={classNames('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm', optionWrapClassName)}>
{items.map((item: Item) => (
<ListboxOption
key={item.value}
className={
classNames(
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
optionClassName,
)
}
value={item}
disabled={disabled}
>
{({ /* active, */ selected }) => (
<>
{renderOption
? renderOption({ item, selected })
: (<>
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
{selected && !hideChecked && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>)}
</>
)}
</ListboxOption>
))}
{items.map((item: Item) =>
item.isGroup ? (
<div
key={item.value}
className="select-none px-3 py-1.5 text-xs font-medium uppercase tracking-wide text-text-tertiary"
>
{item.name}
</div>
) : (
<ListboxOption
key={item.value}
className={
classNames(
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
optionClassName,
)
}
value={item}
disabled={item.disabled || disabled}
>
{({ /* active, */ selected }) => (
<>
{renderOption
? renderOption({ item, selected })
: (<>
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
{selected && !hideChecked && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>)}
</>
)}
</ListboxOption>
),
)}
</ListboxOptions>
)}
</div>

View File

@ -2,13 +2,34 @@ import type { Block } from '../types'
import { BlockEnum } from '../types'
import { BlockClassificationEnum } from './types'
export const BLOCKS: Block[] = [
export const START_BLOCKS: Block[] = [
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.Start,
title: 'Start',
description: '',
title: 'User Input',
description: 'Traditional start node for user input',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.TriggerSchedule,
title: 'Schedule Trigger',
description: 'Time-based workflow trigger',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.TriggerWebhook,
title: 'Webhook Trigger',
description: 'HTTP callback trigger',
},
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.TriggerPlugin,
title: 'Plugin Trigger',
description: 'Third-party integration trigger',
},
]
export const BLOCKS: Block[] = [
{
classification: BlockClassificationEnum.Default,
type: BlockEnum.LLM,

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { BLOCKS } from './constants'
import { BLOCKS, START_BLOCKS } from './constants'
import {
TabsEnum,
ToolTypeEnum,
@ -16,10 +16,21 @@ export const useBlocks = () => {
})
}
export const useTabs = () => {
export const useStartBlocks = () => {
const { t } = useTranslation()
return [
return START_BLOCKS.map((block) => {
return {
...block,
title: t(`workflow.blocks.${block.type}`),
}
})
}
export const useTabs = (showStartTab = false) => {
const { t } = useTranslation()
const tabs = [
{
key: TabsEnum.Blocks,
name: t('workflow.tabs.blocks'),
@ -29,6 +40,15 @@ export const useTabs = () => {
name: t('workflow.tabs.tools'),
},
]
if (showStartTab) {
tabs.push({
key: TabsEnum.Start,
name: t('workflow.tabs.start'),
})
}
return tabs
}
export const useToolTabs = (isHideMCPTools?: boolean) => {

View File

@ -43,6 +43,7 @@ type NodeSelectorProps = {
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
noBlocks?: boolean
showStartTab?: boolean
}
const NodeSelector: FC<NodeSelectorProps> = ({
open: openFromProps,
@ -59,6 +60,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
availableBlocksTypes,
disabled,
noBlocks = false,
showStartTab = false,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
@ -90,9 +92,10 @@ const NodeSelector: FC<NodeSelectorProps> = ({
setActiveTab(newActiveTab)
}, [])
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Start)
return t('workflow.tabs.searchBlock')
if (activeTab === TabsEnum.Blocks)
return t('workflow.tabs.searchBlock')
if (activeTab === TabsEnum.Tools)
return t('workflow.tabs.searchTool')
return ''
@ -163,6 +166,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
tags={tags}
availableBlocksTypes={availableBlocksTypes}
noBlocks={noBlocks}
showStartTab={showStartTab}
/>
</div>
</PortalToFollowElemContent>

View File

@ -0,0 +1,84 @@
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '../block-icon'
import type { BlockEnum } from '../types'
import { useNodesExtraData } from '../hooks'
import { START_BLOCKS } from './constants'
import type { ToolDefaultValue } from './types'
import Tooltip from '@/app/components/base/tooltip'
type StartBlocksProps = {
searchText: string
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
availableBlocksTypes?: BlockEnum[]
}
const StartBlocks = ({
searchText,
onSelect,
availableBlocksTypes = [],
}: StartBlocksProps) => {
const { t } = useTranslation()
const nodesExtraData = useNodesExtraData()
const filteredBlocks = useMemo(() => {
return START_BLOCKS.filter((block) => {
return block.title.toLowerCase().includes(searchText.toLowerCase())
&& availableBlocksTypes.includes(block.type)
})
}, [searchText, availableBlocksTypes])
const isEmpty = filteredBlocks.length === 0
const renderBlock = useCallback((block: typeof START_BLOCKS[0]) => (
<Tooltip
key={block.type}
position='right'
popupClassName='w-[200px]'
needsDelay={false}
popupContent={(
<div>
<BlockIcon
size='md'
className='mb-2'
type={block.type}
/>
<div className='system-md-medium mb-1 text-text-primary'>{block.title}</div>
<div className='system-xs-regular text-text-tertiary'>{nodesExtraData[block.type].about}</div>
</div>
)}
>
<div
className='flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
onClick={() => onSelect(block.type)}
>
<BlockIcon
className='mr-2 shrink-0'
type={block.type}
/>
<div className='grow text-sm text-text-secondary'>{block.title}</div>
</div>
</Tooltip>
), [nodesExtraData, onSelect])
return (
<div className='p-1'>
{isEmpty && (
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>
{t('workflow.tabs.noResult')}
</div>
)}
{!isEmpty && (
<div className='mb-1'>
{filteredBlocks.map(renderBlock)}
</div>
)}
</div>
)
}
export default memo(StartBlocks)

View File

@ -6,6 +6,7 @@ import { useTabs } from './hooks'
import type { ToolDefaultValue } from './types'
import { TabsEnum } from './types'
import Blocks from './blocks'
import StartBlocks from './start-blocks'
import AllTools from './all-tools'
import cn from '@/utils/classnames'
@ -18,6 +19,7 @@ export type TabsProps = {
availableBlocksTypes?: BlockEnum[]
filterElem: React.ReactNode
noBlocks?: boolean
showStartTab?: boolean
}
const Tabs: FC<TabsProps> = ({
activeTab,
@ -28,8 +30,9 @@ const Tabs: FC<TabsProps> = ({
availableBlocksTypes,
filterElem,
noBlocks,
showStartTab = false,
}) => {
const tabs = useTabs()
const tabs = useTabs(showStartTab)
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
@ -60,6 +63,17 @@ const Tabs: FC<TabsProps> = ({
)
}
{filterElem}
{
activeTab === TabsEnum.Start && !noBlocks && (
<div className='border-t border-divider-subtle'>
<StartBlocks
searchText={searchText}
onSelect={onSelect}
availableBlocksTypes={availableBlocksTypes}
/>
</div>
)
}
{
activeTab === TabsEnum.Blocks && !noBlocks && (
<div className='border-t border-divider-subtle'>

View File

@ -1,6 +1,7 @@
import type { PluginMeta } from '../../plugins/types'
export enum TabsEnum {
Start = 'start',
Blocks = 'blocks',
Tools = 'tools',
}

View File

@ -22,6 +22,9 @@ import IterationStartDefault from './nodes/iteration-start/default'
import AgentDefault from './nodes/agent/default'
import LoopStartDefault from './nodes/loop-start/default'
import LoopEndDefault from './nodes/loop-end/default'
import TriggerScheduleDefault from './nodes/trigger-schedule/default'
import TriggerWebhookDefault from './nodes/trigger-webhook/default'
import TriggerPluginDefault from './nodes/trigger-plugin/default'
type NodesExtraData = {
author: string
@ -242,6 +245,33 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
getAvailableNextNodes: ListFilterDefault.getAvailableNextNodes,
checkValid: AgentDefault.checkValid,
},
[BlockEnum.TriggerSchedule]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: TriggerScheduleDefault.getAvailablePrevNodes,
getAvailableNextNodes: TriggerScheduleDefault.getAvailableNextNodes,
checkValid: TriggerScheduleDefault.checkValid,
},
[BlockEnum.TriggerWebhook]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: TriggerWebhookDefault.getAvailablePrevNodes,
getAvailableNextNodes: TriggerWebhookDefault.getAvailableNextNodes,
checkValid: TriggerWebhookDefault.checkValid,
},
[BlockEnum.TriggerPlugin]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: TriggerPluginDefault.getAvailablePrevNodes,
getAvailableNextNodes: TriggerPluginDefault.getAvailableNextNodes,
checkValid: TriggerPluginDefault.checkValid,
},
}
export const NODES_INITIAL_DATA = {
@ -401,6 +431,24 @@ export const NODES_INITIAL_DATA = {
desc: '',
...AgentDefault.defaultValue,
},
[BlockEnum.TriggerSchedule]: {
type: BlockEnum.TriggerSchedule,
title: '',
desc: '',
...TriggerScheduleDefault.defaultValue,
},
[BlockEnum.TriggerWebhook]: {
type: BlockEnum.TriggerWebhook,
title: '',
desc: '',
...TriggerWebhookDefault.defaultValue,
},
[BlockEnum.TriggerPlugin]: {
type: BlockEnum.TriggerPlugin,
title: '',
desc: '',
...TriggerPluginDefault.defaultValue,
},
}
export const MAX_ITERATION_PARALLEL_NUM = 10
export const MIN_ITERATION_PARALLEL_NUM = 1

View File

@ -28,6 +28,10 @@ import {
import {
getParallelInfo,
} from '../utils'
import {
getWorkflowEntryNode,
isWorkflowEntryNode,
} from '../utils/workflow-entry'
import {
PARALLEL_DEPTH_LIMIT,
SUPPORT_OUTPUT_VARS_NODE,
@ -67,7 +71,7 @@ export const useWorkflow = () => {
edges,
} = store.getState()
const nodes = getNodes()
let startNode = nodes.find(node => node.data.type === BlockEnum.Start)
let startNode = getWorkflowEntryNode(nodes)
const currentNode = nodes.find(node => node.id === nodeId)
if (currentNode?.parentId)
@ -238,14 +242,14 @@ export const useWorkflow = () => {
if (!currentNode)
return false
if (currentNode.data.type === BlockEnum.Start)
if (isWorkflowEntryNode(currentNode.data.type))
return true
const checkPreviousNodes = (node: Node) => {
const previousNodes = getBeforeNodeById(node.id)
for (const prevNode of previousNodes) {
if (prevNode.data.type === BlockEnum.Start)
if (isWorkflowEntryNode(prevNode.data.type))
return true
if (checkPreviousNodes(prevNode))
return true
@ -390,7 +394,7 @@ export const useWorkflow = () => {
const { getNodes } = store.getState()
const nodes = getNodes()
return nodes.find(node => node.id === nodeId) || nodes.find(node => node.data.type === BlockEnum.Start)
return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes)
}, [store])
return {

View File

@ -0,0 +1,43 @@
import type { FC, ReactNode } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
export type TriggerStatus = 'enabled' | 'disabled'
type TriggerContainerProps = {
children: ReactNode
status?: TriggerStatus
customLabel?: string
}
const TriggerContainer: FC<TriggerContainerProps> = ({
children,
status = 'enabled',
customLabel,
}) => {
const { t } = useTranslation()
const statusConfig = useMemo(() => {
const isDisabled = status === 'disabled'
return {
label: customLabel || (isDisabled ? t('workflow.triggerStatus.disabled') : t('workflow.triggerStatus.enabled')),
dotColor: isDisabled ? 'bg-text-tertiary' : 'bg-green-500',
}
}, [status, customLabel, t])
return (
<div className="w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-px pb-px pt-0.5">
<div className="mb-0.5 flex items-center px-2 pt-1">
<div className={cn('mr-1 h-2 w-2 rounded-sm', statusConfig.dotColor)} />
<span className="system-2xs-medium-uppercase text-text-tertiary">
{statusConfig.label}
</span>
</div>
{children}
</div>
)
}
export default TriggerContainer

View File

@ -20,6 +20,7 @@ import type { NodeProps } from '../../types'
import {
BlockEnum,
NodeRunningStatus,
TRIGGER_NODE_TYPES,
} from '../../types'
import {
useNodesReadOnly,
@ -42,6 +43,7 @@ import NodeControl from './components/node-control'
import ErrorHandleOnNode from './components/error-handle/error-handle-on-node'
import RetryOnNode from './components/retry/retry-on-node'
import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
import TriggerContainer from './components/trigger-container'
import cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon'
import Tooltip from '@/app/components/base/tooltip'
@ -136,7 +138,9 @@ const BaseNode: FC<BaseNodeProps> = ({
return null
}, [data._loopIndex, data._runningStatus, t])
return (
const isTriggerNode = TRIGGER_NODE_TYPES.includes(data.type as any)
const nodeContent = (
<div
className={cn(
'flex rounded-2xl border-[2px]',
@ -291,13 +295,13 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
{
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
cloneElement(children, { id, data })
cloneElement(children, { data } as any)
)
}
{
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
<div className='grow pb-1 pl-1 pr-1'>
{cloneElement(children, { id, data })}
{cloneElement(children, { data } as any)}
</div>
)
}
@ -332,6 +336,12 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
</div>
)
return isTriggerNode ? (
<TriggerContainer status="enabled">
{nodeContent}
</TriggerContainer>
) : nodeContent
}
export default memo(BaseNode)

View File

@ -38,6 +38,12 @@ import ListFilterNode from './list-operator/node'
import ListFilterPanel from './list-operator/panel'
import AgentNode from './agent/node'
import AgentPanel from './agent/panel'
import TriggerScheduleNode from './trigger-schedule/node'
import TriggerSchedulePanel from './trigger-schedule/panel'
import TriggerWebhookNode from './trigger-webhook/node'
import TriggerWebhookPanel from './trigger-webhook/panel'
import TriggerPluginNode from './trigger-plugin/node'
import TriggerPluginPanel from './trigger-plugin/panel'
import { TransferMethod } from '@/types/app'
export const NodeComponentMap: Record<string, ComponentType<any>> = {
@ -61,6 +67,9 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.DocExtractor]: DocExtractorNode,
[BlockEnum.ListFilter]: ListFilterNode,
[BlockEnum.Agent]: AgentNode,
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
@ -84,6 +93,9 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: AgentPanel,
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
}
export const CUSTOM_NODE_TYPE = 'custom'

View File

@ -0,0 +1,30 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { PluginTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
const nodeDefault: NodeDefault<PluginTriggerNodeType> = {
defaultValue: {
plugin_id: '',
plugin_name: '',
event_type: '',
config: {},
},
getAvailablePrevNodes(isChatMode: boolean) {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(payload: PluginTriggerNodeType, t: any) {
return {
isValid: true,
errorMessage: '',
}
},
}
export default nodeDefault

View File

@ -0,0 +1,28 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { PluginTriggerNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
const i18nPrefix = 'workflow.nodes.triggerPlugin'
const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
data,
}) => {
const { t } = useTranslation()
return (
<div className="mb-1 px-3 py-1">
<div className="text-xs text-gray-700">
{t(`${i18nPrefix}.nodeTitle`)}
</div>
{data.plugin_name && (
<div className="text-xs text-gray-500">
{data.plugin_name}
</div>
)}
</div>
)
}
export default React.memo(Node)

View File

@ -0,0 +1,29 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { PluginTriggerNodeType } from './types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import type { NodePanelProps } from '@/app/components/workflow/types'
const i18nPrefix = 'workflow.nodes.triggerPlugin'
const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
return (
<div className='mt-2'>
<div className='space-y-4 px-4 pb-2'>
<Field title={t(`${i18nPrefix}.title`)}>
<div className="text-sm text-gray-500">
{t(`${i18nPrefix}.configPlaceholder`)}
</div>
</Field>
</div>
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,8 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
export type PluginTriggerNodeType = CommonNodeType & {
plugin_id?: string
plugin_name?: string
event_type?: string
config?: Record<string, any>
}

View File

@ -0,0 +1,139 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import DateTimePicker from './date-time-picker'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'workflow.nodes.triggerSchedule.selectDateTime': 'Select Date & Time',
'common.operation.now': 'Now',
'common.operation.ok': 'OK',
}
return translations[key] || key
},
}),
}))
describe('DateTimePicker', () => {
const mockOnChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
test('renders with default value', () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button.textContent).toMatch(/\d+, \d{4} \d{1,2}:\d{2} [AP]M/)
})
test('renders with provided value', () => {
const testDate = new Date('2024-01-15T14:30:00.000Z')
render(<DateTimePicker value={testDate.toISOString()} onChange={mockOnChange} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
test('opens picker when button is clicked', () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(screen.getByText('Select Date & Time')).toBeInTheDocument()
expect(screen.getByText('Now')).toBeInTheDocument()
expect(screen.getByText('OK')).toBeInTheDocument()
})
test('closes picker when clicking outside', () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(screen.getByText('Select Date & Time')).toBeInTheDocument()
const overlay = document.querySelector('.fixed.inset-0')
fireEvent.click(overlay!)
expect(screen.queryByText('Select Date & Time')).not.toBeInTheDocument()
})
test('does not call onChange when input changes without clicking OK', () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
const overlay = document.querySelector('.fixed.inset-0')
fireEvent.click(overlay!)
expect(mockOnChange).not.toHaveBeenCalled()
})
test('calls onChange when clicking OK button', () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
const okButton = screen.getByText('OK')
fireEvent.click(okButton)
expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/2024-12-25T.*:30.*Z/))
})
test('calls onChange when clicking Now button', () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const nowButton = screen.getByText('Now')
fireEvent.click(nowButton)
expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/))
})
test('resets temp value when reopening picker', async () => {
render(<DateTimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
const originalValue = input.getAttribute('value')
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
expect(input.getAttribute('value')).toBe('2024-12-25T15:30')
const overlay = document.querySelector('.fixed.inset-0')
fireEvent.click(overlay!)
fireEvent.click(button)
await waitFor(() => {
const newInput = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
expect(newInput.getAttribute('value')).toBe(originalValue)
})
})
test('displays current value in button text', () => {
const testDate = new Date('2024-01-15T14:30:00.000Z')
render(<DateTimePicker value={testDate.toISOString()} onChange={mockOnChange} />)
const button = screen.getByRole('button')
expect(button.textContent).toMatch(/January 15, 2024/)
expect(button.textContent).toMatch(/\d{1,2}:30 [AP]M/)
})
})

View File

@ -0,0 +1,158 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCalendarLine } from '@remixicon/react'
import { getDefaultDateTime } from '../utils/execution-time-calculator'
type DateTimePickerProps = {
value?: string
onChange: (datetime: string) => void
}
const DateTimePicker = ({ value, onChange }: DateTimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [tempValue, setTempValue] = useState('')
React.useEffect(() => {
if (isOpen)
setTempValue('')
}, [isOpen])
const getCurrentDateTime = () => {
if (value) {
try {
const date = new Date(value)
return `${date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})} ${date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}`
}
catch {
// fallback
}
}
const defaultDate = getDefaultDateTime()
return `${defaultDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})} ${defaultDate.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}`
}
const handleDateTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const dateTimeValue = event.target.value
setTempValue(dateTimeValue)
}
const getInputValue = () => {
if (tempValue)
return tempValue
if (value) {
try {
const date = new Date(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
catch {
// fallback
}
}
const defaultDate = getDefaultDateTime()
const year = defaultDate.getFullYear()
const month = String(defaultDate.getMonth() + 1).padStart(2, '0')
const day = String(defaultDate.getDate()).padStart(2, '0')
const hours = String(defaultDate.getHours()).padStart(2, '0')
const minutes = String(defaultDate.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-9 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-1.5 text-sm text-text-secondary hover:bg-components-input-bg-hover"
>
<span>{getCurrentDateTime()}</span>
<RiCalendarLine className="h-4 w-4 text-gray-400" />
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-72 select-none rounded-xl border border-gray-200 bg-white p-4 shadow-lg">
<div className="mb-3">
<h3 className="text-sm font-medium text-gray-900">{t('workflow.nodes.triggerSchedule.selectDateTime')}</h3>
</div>
<div className="mb-4 border-b border-gray-100" />
<div className="mb-4">
<input
type="datetime-local"
value={getInputValue()}
onChange={handleDateTimeChange}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const now = new Date()
onChange(now.toISOString())
setTempValue('')
setIsOpen(false)
}}
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{t('common.operation.now')}
</button>
<button
type="button"
onClick={() => {
if (tempValue) {
const date = new Date(tempValue)
onChange(date.toISOString())
}
setTempValue('')
setIsOpen(false)
}}
className="flex-1 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{t('common.operation.ok')}
</button>
</div>
</div>
)}
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => {
setTempValue('')
setIsOpen(false)
}}
/>
)}
</div>
)
}
export default DateTimePicker

View File

@ -0,0 +1,24 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
type ExecuteNowButtonProps = {
onClick: () => void
disabled?: boolean
}
const ExecuteNowButton = ({ onClick, disabled = false }: ExecuteNowButtonProps) => {
const { t } = useTranslation()
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-full rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-1.5 text-xs font-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{t('workflow.nodes.triggerSchedule.executeNow')}
</button>
)
}
export default ExecuteNowButton

View File

@ -0,0 +1,38 @@
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SimpleSelect } from '@/app/components/base/select'
import type { ScheduleFrequency } from '../types'
type FrequencySelectorProps = {
frequency: ScheduleFrequency
onChange: (frequency: ScheduleFrequency) => void
}
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
const { t } = useTranslation()
const frequencies = useMemo(() => [
{ value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true },
{ value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') },
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
{ value: 'once', name: t('workflow.nodes.triggerSchedule.frequency.once') },
], [t])
return (
<SimpleSelect
key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render
items={frequencies}
defaultValue={frequency}
onSelect={item => onChange(item.value as ScheduleFrequency)}
placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')}
className="w-full"
optionWrapClassName="min-w-40"
notClearable={true}
allowSearch={false}
/>
)
}
export default FrequencySelector

View File

@ -0,0 +1,37 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiCalendarLine, RiCodeLine } from '@remixicon/react'
import { SegmentedControl } from '@/app/components/base/segmented-control'
import type { ScheduleMode } from '../types'
type ModeSwitcherProps = {
mode: ScheduleMode
onChange: (mode: ScheduleMode) => void
}
const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => {
const { t } = useTranslation()
const options = [
{
Icon: RiCalendarLine,
text: t('workflow.nodes.triggerSchedule.mode.visual'),
value: 'visual' as const,
},
{
Icon: RiCodeLine,
text: t('workflow.nodes.triggerSchedule.mode.cron'),
value: 'cron' as const,
},
]
return (
<SegmentedControl
options={options}
value={mode}
onChange={onChange}
/>
)
}
export default ModeSwitcher

View File

@ -0,0 +1,37 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiAsterisk, RiCalendarLine } from '@remixicon/react'
import type { ScheduleMode } from '../types'
type ModeToggleProps = {
mode: ScheduleMode
onChange: (mode: ScheduleMode) => void
}
const ModeToggle = ({ mode, onChange }: ModeToggleProps) => {
const { t } = useTranslation()
const handleToggle = () => {
const newMode = mode === 'visual' ? 'cron' : 'visual'
onChange(newMode)
}
const currentText = mode === 'visual'
? t('workflow.nodes.triggerSchedule.useCronExpression')
: t('workflow.nodes.triggerSchedule.useVisualPicker')
const currentIcon = mode === 'visual' ? RiAsterisk : RiCalendarLine
return (
<button
type="button"
onClick={handleToggle}
className="flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-state-base-hover"
>
{React.createElement(currentIcon, { className: 'w-3 h-3' })}
<span>{currentText}</span>
</button>
)
}
export default ModeToggle

View File

@ -0,0 +1,70 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiQuestionLine } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
type MonthlyDaysSelectorProps = {
selectedDay: number | 'last'
onChange: (day: number | 'last') => void
}
const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps) => {
const { t } = useTranslation()
const days = Array.from({ length: 31 }, (_, i) => i + 1)
const rows = [
days.slice(0, 7),
days.slice(7, 14),
days.slice(14, 21),
days.slice(21, 28),
[29, 30, 31, 'last' as const],
]
return (
<div className="space-y-2">
<label className="mb-2 block text-xs font-medium text-gray-500">
{t('workflow.nodes.triggerSchedule.days')}
</label>
<div className="space-y-1.5">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="grid grid-cols-7 gap-1.5">
{row.map(day => (
<button
key={day}
type="button"
onClick={() => onChange(day)}
className={`rounded-lg py-1.5 text-xs transition-colors ${
day === 'last' ? 'col-span-2 min-w-0' : ''
} ${
selectedDay === day
? 'border-2 border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
: 'border-components-input-border-normal border text-text-tertiary hover:border-components-input-border-hover hover:text-text-secondary'
}`}
>
{day === 'last' ? (
<div className="flex items-center justify-center gap-1">
<span>{t('workflow.nodes.triggerSchedule.lastDay')}</span>
<Tooltip
popupContent={t('workflow.nodes.triggerSchedule.lastDayTooltip')}
>
<RiQuestionLine className="h-3 w-3 text-text-quaternary" />
</Tooltip>
</div>
) : (
day
)}
</button>
))}
{/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */}
{rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => (
<div key={`empty-${i}`} className="invisible"></div>
))}
</div>
))}
</div>
</div>
)
}
export default MonthlyDaysSelector

View File

@ -0,0 +1,41 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { ScheduleTriggerNodeType } from '../types'
import { getFormattedExecutionTimes } from '../utils/execution-time-calculator'
type NextExecutionTimesProps = {
data: ScheduleTriggerNodeType
}
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
const { t } = useTranslation()
// Don't show next execution times for 'once' frequency
if (data.frequency === 'once')
return null
const executionTimes = getFormattedExecutionTimes(data, 5)
if (executionTimes.length === 0)
return null
return (
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-500">
{t('workflow.nodes.triggerSchedule.nextExecutionTimes')}
</label>
<div className="space-y-2 rounded-lg bg-components-input-bg-normal p-3">
{executionTimes.map((time, index) => (
<div key={index} className="flex items-baseline gap-3 text-xs text-text-secondary">
<span className="select-none font-mono leading-none text-text-quaternary">
{String(index + 1).padStart(2, '0')}
</span>
<span className="leading-none">{time}</span>
</div>
))}
</div>
</div>
)
}
export default NextExecutionTimes

View File

@ -0,0 +1,59 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { InputNumber } from '@/app/components/base/input-number'
import { SimpleSegmentedControl } from './simple-segmented-control'
type RecurConfigProps = {
recurEvery?: number
recurUnit?: 'hours' | 'minutes'
onRecurEveryChange: (value: number) => void
onRecurUnitChange: (unit: 'hours' | 'minutes') => void
}
const RecurConfig = ({
recurEvery = 1,
recurUnit = 'hours',
onRecurEveryChange,
onRecurUnitChange,
}: RecurConfigProps) => {
const { t } = useTranslation()
const unitOptions = [
{
text: t('workflow.nodes.triggerSchedule.hours'),
value: 'hours' as const,
},
{
text: t('workflow.nodes.triggerSchedule.minutes'),
value: 'minutes' as const,
},
]
return (
<div className="flex gap-3">
<div className="flex-[2]">
<label className="mb-2 block text-xs font-medium text-text-tertiary">
{t('workflow.nodes.triggerSchedule.recurEvery')}
</label>
<InputNumber
value={recurEvery}
onChange={value => onRecurEveryChange(value || 1)}
min={1}
className="text-center"
/>
</div>
<div className="flex-1">
<label className="mb-2 block text-xs font-medium text-text-tertiary">
&nbsp;
</label>
<SimpleSegmentedControl
options={unitOptions}
value={recurUnit}
onChange={onRecurUnitChange}
/>
</div>
</div>
)
}
export default RecurConfig

View File

@ -0,0 +1,60 @@
import React from 'react'
import classNames from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
// Simplified version without icons
type SimpleSegmentedControlProps<T extends string | number | symbol> = {
options: { text: string, value: T }[]
value: T
onChange: (value: T) => void
className?: string
}
export const SimpleSegmentedControl = <T extends string | number | symbol>({
options,
value,
onChange,
className,
}: SimpleSegmentedControlProps<T>) => {
const selectedOptionIndex = options.findIndex(option => option.value === value)
return (
<div className={classNames(
'flex items-center gap-x-[1px] rounded-lg bg-components-segmented-control-bg-normal p-0.5',
className,
)}>
{options.map((option, index) => {
const isSelected = index === selectedOptionIndex
const isNextSelected = index === selectedOptionIndex - 1
const isLast = index === options.length - 1
return (
<button
type='button'
key={String(option.value)}
className={classNames(
'border-0.5 group relative flex flex-1 items-center justify-center gap-x-0.5 rounded-lg border-transparent px-2 py-1',
isSelected
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
: 'hover:bg-state-base-hover',
)}
onClick={() => onChange(option.value)}
>
<span className={classNames(
'system-sm-medium p-0.5 text-text-tertiary',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)}>
{option.text}
</span>
{!isLast && !isSelected && !isNextSelected && (
<div className='absolute right-[-1px] top-0 flex h-full items-center'>
<Divider type='vertical' className='mx-0 h-3.5' />
</div>
)}
</button>
)
})}
</div>
)
}
export default React.memo(SimpleSegmentedControl) as typeof SimpleSegmentedControl

View File

@ -0,0 +1,223 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import TimePicker from './time-picker'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'time.title.pickTime': 'Pick Time',
'common.operation.now': 'Now',
'common.operation.ok': 'OK',
}
return translations[key] || key
},
}),
}))
describe('TimePicker', () => {
const mockOnChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
test('renders with default value', () => {
render(<TimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button.textContent).toBe('11:30 AM')
})
test('renders with provided value', () => {
render(<TimePicker value="2:30 PM" onChange={mockOnChange} />)
const button = screen.getByRole('button')
expect(button.textContent).toBe('2:30 PM')
})
test('opens picker when button is clicked', () => {
render(<TimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(screen.getByText('Pick Time')).toBeInTheDocument()
expect(screen.getByText('Now')).toBeInTheDocument()
expect(screen.getByText('OK')).toBeInTheDocument()
})
test('closes picker when clicking outside', () => {
render(<TimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(screen.getByText('Pick Time')).toBeInTheDocument()
const overlay = document.querySelector('.fixed.inset-0')
fireEvent.click(overlay!)
expect(screen.queryByText('Pick Time')).not.toBeInTheDocument()
})
test('button text remains unchanged when selecting time without clicking OK', () => {
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
const button = screen.getByRole('button')
expect(button.textContent).toBe('11:30 AM')
fireEvent.click(button)
const hourButton = screen.getByText('3')
fireEvent.click(hourButton)
const minuteButton = screen.getByText('45')
fireEvent.click(minuteButton)
const pmButton = screen.getByText('PM')
fireEvent.click(pmButton)
expect(button.textContent).toBe('11:30 AM')
expect(mockOnChange).not.toHaveBeenCalled()
const overlay = document.querySelector('.fixed.inset-0')
fireEvent.click(overlay!)
expect(button.textContent).toBe('11:30 AM')
})
test('calls onChange when clicking OK button', () => {
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const hourButton = screen.getByText('3')
fireEvent.click(hourButton)
const minuteButton = screen.getByText('45')
fireEvent.click(minuteButton)
const pmButton = screen.getByText('PM')
fireEvent.click(pmButton)
const okButton = screen.getByText('OK')
fireEvent.click(okButton)
expect(mockOnChange).toHaveBeenCalledWith('3:45 PM')
})
test('calls onChange when clicking Now button', () => {
const mockDate = new Date('2024-01-15T14:30:00')
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate)
render(<TimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const nowButton = screen.getByText('Now')
fireEvent.click(nowButton)
expect(mockOnChange).toHaveBeenCalledWith('2:30 PM')
jest.restoreAllMocks()
})
test('initializes picker with current value when opened', async () => {
render(<TimePicker value="3:45 PM" onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
const selectedHour = screen.getByText('3').closest('button')
expect(selectedHour).toHaveClass('bg-gray-100')
const selectedMinute = screen.getByText('45').closest('button')
expect(selectedMinute).toHaveClass('bg-gray-100')
const selectedPeriod = screen.getByText('PM').closest('button')
expect(selectedPeriod).toHaveClass('bg-gray-100')
})
})
test('resets picker selection when reopening after closing without OK', async () => {
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const hourButton = screen.getByText('3')
fireEvent.click(hourButton)
const overlay = document.querySelector('.fixed.inset-0')
fireEvent.click(overlay!)
fireEvent.click(button)
await waitFor(() => {
const hourButtons = screen.getAllByText('11')
const selectedHourButton = hourButtons.find(btn => btn.closest('button')?.classList.contains('bg-gray-100'))
expect(selectedHourButton).toBeTruthy()
const notSelectedHour = screen.getByText('3').closest('button')
expect(notSelectedHour).not.toHaveClass('bg-gray-100')
})
})
test('handles 12 AM/PM correctly in Now button', () => {
const mockMidnight = new Date('2024-01-15T00:30:00')
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockMidnight)
render(<TimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const nowButton = screen.getByText('Now')
fireEvent.click(nowButton)
expect(mockOnChange).toHaveBeenCalledWith('12:30 AM')
jest.restoreAllMocks()
})
test('handles 12 PM correctly in Now button', () => {
const mockNoon = new Date('2024-01-15T12:30:00')
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockNoon)
render(<TimePicker onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const nowButton = screen.getByText('Now')
fireEvent.click(nowButton)
expect(mockOnChange).toHaveBeenCalledWith('12:30 PM')
jest.restoreAllMocks()
})
test('auto-scrolls to selected values when opened', async () => {
const mockScrollIntoView = jest.fn()
Element.prototype.scrollIntoView = mockScrollIntoView
render(<TimePicker value="8:45 PM" onChange={mockOnChange} />)
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
expect(mockScrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
})
}, { timeout: 200 })
mockScrollIntoView.mockRestore()
})
})

View File

@ -0,0 +1,230 @@
import React, { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiTimeLine } from '@remixicon/react'
const scrollbarHideStyles = {
scrollbarWidth: 'none' as const,
msOverflowStyle: 'none' as const,
} as React.CSSProperties
type TimePickerProps = {
value?: string
onChange: (time: string) => void
}
const TimePicker = ({ value = '11:30 AM', onChange }: TimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [selectedHour, setSelectedHour] = useState(11)
const [selectedMinute, setSelectedMinute] = useState(30)
const [selectedPeriod, setSelectedPeriod] = useState<'AM' | 'PM'>('AM')
const hourContainerRef = useRef<HTMLDivElement>(null)
const minuteContainerRef = useRef<HTMLDivElement>(null)
const periodContainerRef = useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (isOpen) {
if (value) {
const match = value.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/)
if (match) {
setSelectedHour(Number.parseInt(match[1], 10))
setSelectedMinute(Number.parseInt(match[2], 10))
setSelectedPeriod(match[3] as 'AM' | 'PM')
}
}
else {
setSelectedHour(11)
setSelectedMinute(30)
setSelectedPeriod('AM')
}
}
}, [isOpen, value])
React.useEffect(() => {
if (isOpen) {
setTimeout(() => {
if (hourContainerRef.current) {
const selectedHourElement = hourContainerRef.current.querySelector('.bg-state-base-active')
if (selectedHourElement)
selectedHourElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
if (minuteContainerRef.current) {
const selectedMinuteElement = minuteContainerRef.current.querySelector('.bg-state-base-active')
if (selectedMinuteElement)
selectedMinuteElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
if (periodContainerRef.current) {
const selectedPeriodElement = periodContainerRef.current.querySelector('.bg-state-base-active')
if (selectedPeriodElement)
selectedPeriodElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, 50)
}
}, [isOpen, selectedHour, selectedMinute, selectedPeriod])
const hours = Array.from({ length: 12 }, (_, i) => i + 1)
const minutes = Array.from({ length: 60 }, (_, i) => i)
const periods = ['AM', 'PM'] as const
// Create padding elements to ensure bottom options can scroll to top
// Container shows 8 options (h-64), so we need 7 padding elements at bottom
const createBottomPadding = () => Array.from({ length: 7 }, (_, i) => (
<div key={`bottom-padding-${i}`} className="pointer-events-none h-8" />
))
const handleNow = () => {
const now = new Date()
const hour = now.getHours()
const minute = now.getMinutes()
const period = hour >= 12 ? 'PM' : 'AM'
let displayHour = hour
if (hour === 0)
displayHour = 12
else if (hour > 12)
displayHour = hour - 12
const timeString = `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`
onChange(timeString)
setIsOpen(false)
}
const handleOK = () => {
const timeString = `${selectedHour}:${selectedMinute.toString().padStart(2, '0')} ${selectedPeriod}`
onChange(timeString)
setIsOpen(false)
}
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex h-9 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-1.5 text-sm text-text-secondary hover:bg-components-input-bg-hover"
>
<span>{value}</span>
<RiTimeLine className="h-4 w-4 text-text-tertiary" />
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-72 select-none rounded-xl border border-components-panel-border bg-components-panel-bg p-4 shadow-lg">
<div className="mb-3">
<h3 className="text-sm font-semibold text-text-primary">{t('time.title.pickTime')}</h3>
</div>
<div className="mb-4 border-b border-components-panel-border-subtle" />
<div className="mb-4 flex gap-3">
{/* Hours */}
<div className="flex-1">
<div
ref={hourContainerRef}
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={scrollbarHideStyles}
data-testid="hour-selector"
>
{hours.map(hour => (
<button
key={hour}
type="button"
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
selectedHour === hour
? 'bg-state-base-active text-text-primary'
: 'text-text-secondary hover:bg-state-base-hover'
}`}
onClick={() => setSelectedHour(hour)}
>
{hour}
</button>
))}
{createBottomPadding()}
</div>
</div>
{/* Minutes */}
<div className="flex-1">
<div
ref={minuteContainerRef}
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={scrollbarHideStyles}
data-testid="minute-selector"
>
{minutes.map(minute => (
<button
key={minute}
type="button"
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
selectedMinute === minute
? 'bg-state-base-active text-text-primary'
: 'text-text-secondary hover:bg-state-base-hover'
}`}
onClick={() => setSelectedMinute(minute)}
>
{minute.toString().padStart(2, '0')}
</button>
))}
{createBottomPadding()}
</div>
</div>
{/* AM/PM */}
<div className="flex-1">
<div
ref={periodContainerRef}
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={scrollbarHideStyles}
>
{periods.map(period => (
<button
key={period}
type="button"
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
selectedPeriod === period
? 'bg-state-base-active text-text-primary'
: 'text-text-secondary hover:bg-state-base-hover'
}`}
onClick={() => setSelectedPeriod(period)}
>
{period}
</button>
))}
{createBottomPadding()}
</div>
</div>
</div>
{/* Divider */}
<div className="my-4 border-b border-components-panel-border-subtle" />
{/* Buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={handleNow}
className="flex-1 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-1 text-sm font-medium text-text-accent hover:bg-components-button-secondary-bg-hover"
>
{t('common.operation.now')}
</button>
<button
type="button"
onClick={handleOK}
className="flex-1 rounded-lg bg-components-button-primary-bg px-3 py-1 text-sm font-medium text-white hover:bg-components-button-primary-bg-hover"
>
{t('common.operation.ok')}
</button>
</div>
</div>
)}
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</div>
)
}
export default TimePicker

View File

@ -0,0 +1,53 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
type WeekdaySelectorProps = {
selectedDays: string[]
onChange: (days: string[]) => void
}
const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
const { t } = useTranslation()
const weekdays = [
{ key: 'sun', label: 'Sun' },
{ key: 'mon', label: 'Mon' },
{ key: 'tue', label: 'Tue' },
{ key: 'wed', label: 'Wed' },
{ key: 'thu', label: 'Thu' },
{ key: 'fri', label: 'Fri' },
{ key: 'sat', label: 'Sat' },
]
const selectedDay = selectedDays.length > 0 ? selectedDays[0] : 'sun'
const handleDaySelect = (dayKey: string) => {
onChange([dayKey])
}
return (
<div className="space-y-2">
<label className="mb-2 block text-xs font-medium text-gray-500">
{t('workflow.nodes.triggerSchedule.weekdays')}
</label>
<div className="flex gap-1.5">
{weekdays.map(day => (
<button
key={day.key}
type="button"
className={`flex-1 rounded-lg py-1.5 text-xs transition-colors ${
selectedDay === day.key
? 'border-2 border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
: 'border-components-input-border-normal border text-text-tertiary hover:border-components-input-border-hover hover:text-text-secondary'
}`}
onClick={() => handleDaySelect(day.key)}
>
{day.label}
</button>
))}
</div>
</div>
)
}
export default WeekdaySelector

View File

@ -0,0 +1,35 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { ScheduleTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
defaultValue: {
mode: 'visual',
frequency: 'daily',
cron_expression: '',
visual_config: {
time: '11:30 AM',
weekdays: ['sun'],
},
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true,
},
getAvailablePrevNodes(_isChatMode: boolean) {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(_payload: ScheduleTriggerNodeType, _t: any) {
return {
isValid: true,
errorMessage: '',
}
},
}
export default nodeDefault

View File

@ -0,0 +1,27 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { ScheduleTriggerNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import { getNextExecutionTime } from './utils/execution-time-calculator'
const i18nPrefix = 'workflow.nodes.triggerSchedule'
const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({
data,
}) => {
const { t } = useTranslation()
return (
<div className="mb-1 px-3 py-1">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary">
{t(`${i18nPrefix}.nextExecutionTime`)}
</div>
<div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary">
{getNextExecutionTime(data)}
</div>
</div>
)
}
export default React.memo(Node)

View File

@ -0,0 +1,166 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { ScheduleTriggerNodeType } from './types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import type { NodePanelProps } from '@/app/components/workflow/types'
import ModeToggle from './components/mode-toggle'
import FrequencySelector from './components/frequency-selector'
import WeekdaySelector from './components/weekday-selector'
import TimePicker from './components/time-picker'
import DateTimePicker from './components/date-time-picker'
import NextExecutionTimes from './components/next-execution-times'
import ExecuteNowButton from './components/execute-now-button'
import RecurConfig from './components/recur-config'
import MonthlyDaysSelector from './components/monthly-days-selector'
import Input from '@/app/components/base/input'
import useConfig from './use-config'
const i18nPrefix = 'workflow.nodes.triggerSchedule'
const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const {
inputs,
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleRecurEveryChange,
handleRecurUnitChange,
} = useConfig(id, data)
const handleExecuteNow = () => {
// TODO: Implement execute now functionality
console.log('Execute now clicked')
}
return (
<div className='mt-2'>
<div className='space-y-4 px-4 pb-3 pt-2'>
<Field
title={t(`${i18nPrefix}.title`)}
operations={
<ModeToggle
mode={inputs.mode}
onChange={handleModeChange}
/>
}
>
<div className="space-y-3">
{inputs.mode === 'visual' && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-2 block text-xs font-medium text-gray-500">
{t('workflow.nodes.triggerSchedule.frequencyLabel')}
</label>
<FrequencySelector
frequency={inputs.frequency}
onChange={handleFrequencyChange}
/>
</div>
<div className="col-span-2">
<label className="mb-2 block text-xs font-medium text-gray-500">
{inputs.frequency === 'hourly' || inputs.frequency === 'once'
? t('workflow.nodes.triggerSchedule.startTime')
: t('workflow.nodes.triggerSchedule.time')
}
</label>
{inputs.frequency === 'hourly' || inputs.frequency === 'once' ? (
<DateTimePicker
value={inputs.visual_config?.datetime}
onChange={(datetime) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
datetime,
},
}
setInputs(newInputs)
}}
/>
) : (
<TimePicker
value={inputs.visual_config?.time || '11:30 AM'}
onChange={handleTimeChange}
/>
)}
</div>
</div>
{inputs.frequency === 'weekly' && (
<WeekdaySelector
selectedDays={inputs.visual_config?.weekdays || []}
onChange={handleWeekdaysChange}
/>
)}
{inputs.frequency === 'hourly' && (
<RecurConfig
recurEvery={inputs.visual_config?.recur_every}
recurUnit={inputs.visual_config?.recur_unit}
onRecurEveryChange={handleRecurEveryChange}
onRecurUnitChange={handleRecurUnitChange}
/>
)}
{inputs.frequency === 'monthly' && (
<MonthlyDaysSelector
selectedDay={inputs.visual_config?.monthly_day || 1}
onChange={(day) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
monthly_day: day,
},
}
setInputs(newInputs)
}}
/>
)}
</div>
)}
{inputs.mode === 'cron' && (
<div className="space-y-2">
<div>
<label className="mb-2 block text-xs font-medium text-gray-500">
{t('workflow.nodes.triggerSchedule.cronExpression')}
</label>
<Input
value={inputs.cron_expression || ''}
onChange={e => handleCronExpressionChange(e.target.value)}
placeholder="0 0 * * *"
className="font-mono"
/>
</div>
<div className="text-xs text-gray-500">
Enter cron expression (minute hour day month weekday)
</div>
</div>
)}
</div>
</Field>
<div className="border-t border-divider-subtle"></div>
<NextExecutionTimes data={inputs} />
<div className="pt-2">
<ExecuteNowButton onClick={handleExecuteNow} />
</div>
</div>
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,24 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
export type ScheduleMode = 'visual' | 'cron'
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'once'
export type VisualConfig = {
time?: string
datetime?: string
days?: number[]
weekdays?: string[]
recur_every?: number
recur_unit?: 'hours' | 'minutes'
monthly_day?: number | 'last'
}
export type ScheduleTriggerNodeType = CommonNodeType & {
mode: ScheduleMode
frequency: ScheduleFrequency
cron_expression?: string
visual_config?: VisualConfig
timezone: string
enabled: boolean
}

View File

@ -0,0 +1,106 @@
import { useCallback } from 'react'
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const defaultPayload = {
...payload,
mode: payload.mode || 'visual',
frequency: payload.frequency || 'daily',
visual_config: {
time: '11:30 AM',
weekdays: ['sun'],
...payload.visual_config,
},
timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: payload.enabled !== undefined ? payload.enabled : true,
}
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, defaultPayload)
const handleModeChange = useCallback((mode: ScheduleMode) => {
const newInputs = {
...inputs,
mode,
}
setInputs(newInputs)
}, [inputs, setInputs])
const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => {
const newInputs = {
...inputs,
frequency,
}
setInputs(newInputs)
}, [inputs, setInputs])
const handleCronExpressionChange = useCallback((value: string) => {
const newInputs = {
...inputs,
cron_expression: value,
}
setInputs(newInputs)
}, [inputs, setInputs])
const handleWeekdaysChange = useCallback((weekdays: string[]) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
weekdays,
},
}
setInputs(newInputs)
}, [inputs, setInputs])
const handleTimeChange = useCallback((time: string) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
time,
},
}
setInputs(newInputs)
}, [inputs, setInputs])
const handleRecurEveryChange = useCallback((recur_every: number) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
recur_every,
},
}
setInputs(newInputs)
}, [inputs, setInputs])
const handleRecurUnitChange = useCallback((recur_unit: 'hours' | 'minutes') => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
recur_unit,
},
}
setInputs(newInputs)
}, [inputs, setInputs])
return {
readOnly,
inputs,
setInputs,
handleModeChange,
handleFrequencyChange,
handleCronExpressionChange,
handleWeekdaysChange,
handleTimeChange,
handleRecurEveryChange,
handleRecurUnitChange,
}
}
export default useConfig

View File

@ -0,0 +1,233 @@
import { isValidCronExpression, parseCronExpression } from './cron-parser'
describe('cron-parser', () => {
describe('isValidCronExpression', () => {
test('validates correct cron expressions', () => {
expect(isValidCronExpression('15 10 1 * *')).toBe(true)
expect(isValidCronExpression('0 0 * * 0')).toBe(true)
expect(isValidCronExpression('*/5 * * * *')).toBe(true)
expect(isValidCronExpression('0 9-17 * * 1-5')).toBe(true)
expect(isValidCronExpression('30 14 * * 1')).toBe(true)
expect(isValidCronExpression('0 0 1,15 * *')).toBe(true)
})
test('rejects invalid cron expressions', () => {
expect(isValidCronExpression('')).toBe(false)
expect(isValidCronExpression('15 10 1')).toBe(false) // Not enough fields
expect(isValidCronExpression('15 10 1 * * *')).toBe(false) // Too many fields
expect(isValidCronExpression('60 10 1 * *')).toBe(false) // Invalid minute
expect(isValidCronExpression('15 25 1 * *')).toBe(false) // Invalid hour
expect(isValidCronExpression('15 10 32 * *')).toBe(false) // Invalid day
expect(isValidCronExpression('15 10 1 13 *')).toBe(false) // Invalid month
expect(isValidCronExpression('15 10 1 * 7')).toBe(false) // Invalid day of week
})
test('handles edge cases', () => {
expect(isValidCronExpression(' 15 10 1 * * ')).toBe(true) // Whitespace
expect(isValidCronExpression('0 0 29 2 *')).toBe(true) // Feb 29 (valid in leap years)
expect(isValidCronExpression('59 23 31 12 6')).toBe(true) // Max values
})
})
describe('parseCronExpression', () => {
beforeEach(() => {
// Mock current time to make tests deterministic
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
})
afterEach(() => {
jest.useRealTimers()
})
test('parses monthly expressions correctly', () => {
const result = parseCronExpression('15 10 1 * *') // 1st day of every month at 10:15
expect(result).toHaveLength(5)
expect(result[0].getDate()).toBe(1) // February 1st
expect(result[0].getHours()).toBe(10)
expect(result[0].getMinutes()).toBe(15)
expect(result[1].getDate()).toBe(1) // March 1st
expect(result[2].getDate()).toBe(1) // April 1st
})
test('parses weekly expressions correctly', () => {
const result = parseCronExpression('30 14 * * 1') // Every Monday at 14:30
expect(result).toHaveLength(5)
// Should find next 5 Mondays
result.forEach((date) => {
expect(date.getDay()).toBe(1) // Monday
expect(date.getHours()).toBe(14)
expect(date.getMinutes()).toBe(30)
})
})
test('parses daily expressions correctly', () => {
const result = parseCronExpression('0 9 * * *') // Every day at 9:00
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getHours()).toBe(9)
expect(date.getMinutes()).toBe(0)
})
// Should be consecutive days (starting from tomorrow since current time is 10:00)
for (let i = 1; i < result.length; i++) {
const prevDate = new Date(result[i - 1])
const currDate = new Date(result[i])
const dayDiff = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
expect(dayDiff).toBe(1)
}
})
test('handles complex cron expressions with ranges', () => {
const result = parseCronExpression('0 9-17 * * 1-5') // Weekdays, 9-17 hours
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getDay()).toBeGreaterThanOrEqual(1) // Monday
expect(date.getDay()).toBeLessThanOrEqual(5) // Friday
expect(date.getHours()).toBeGreaterThanOrEqual(9)
expect(date.getHours()).toBeLessThanOrEqual(17)
expect(date.getMinutes()).toBe(0)
})
})
test('handles step expressions', () => {
const result = parseCronExpression('*/15 * * * *') // Every 15 minutes
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getMinutes() % 15).toBe(0)
})
})
test('handles list expressions', () => {
const result = parseCronExpression('0 0 1,15 * *') // 1st and 15th of each month
expect(result).toHaveLength(5)
result.forEach((date) => {
expect([1, 15]).toContain(date.getDate())
expect(date.getHours()).toBe(0)
expect(date.getMinutes()).toBe(0)
})
})
test('handles expressions that span multiple months', () => {
// Test with an expression that might not have many matches in current month
const result = parseCronExpression('0 12 29 * *') // 29th of each month at noon
expect(result.length).toBeGreaterThan(0)
expect(result.length).toBeLessThanOrEqual(5)
result.forEach((date) => {
expect(date.getDate()).toBe(29)
expect(date.getHours()).toBe(12)
expect(date.getMinutes()).toBe(0)
})
})
test('returns empty array for invalid expressions', () => {
expect(parseCronExpression('')).toEqual([])
expect(parseCronExpression('invalid')).toEqual([])
expect(parseCronExpression('60 10 1 * *')).toEqual([])
expect(parseCronExpression('15 25 1 * *')).toEqual([])
})
test('handles edge case: February 29th in non-leap years', () => {
// Set to a non-leap year
jest.setSystemTime(new Date('2023-01-15T10:00:00Z'))
const result = parseCronExpression('0 12 29 2 *') // Feb 29th at noon
// Should return empty or skip 2023 and find 2024
if (result.length > 0) {
result.forEach((date) => {
expect(date.getMonth()).toBe(1) // February
expect(date.getDate()).toBe(29)
// Should be in a leap year
const year = date.getFullYear()
expect(year % 4).toBe(0)
})
}
})
test('sorts results chronologically', () => {
const result = parseCronExpression('0 */6 * * *') // Every 6 hours
expect(result).toHaveLength(5)
for (let i = 1; i < result.length; i++)
expect(result[i].getTime()).toBeGreaterThan(result[i - 1].getTime())
})
test('excludes past times', () => {
// Set current time to 15:30
jest.setSystemTime(new Date('2024-01-15T15:30:00Z'))
const result = parseCronExpression('0 10 * * *') // Daily at 10:00
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getTime()).toBeGreaterThan(Date.now())
})
// First result should be tomorrow since today's 10:00 has passed
expect(result[0].getDate()).toBe(16)
})
test('handles midnight expressions correctly', () => {
const result = parseCronExpression('0 0 * * *') // Daily at midnight
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getHours()).toBe(0)
expect(date.getMinutes()).toBe(0)
})
})
test('handles year boundary correctly', () => {
// Set to end of December
jest.setSystemTime(new Date('2024-12-30T10:00:00Z'))
const result = parseCronExpression('0 12 1 * *') // 1st of every month at noon
expect(result).toHaveLength(5)
// Should include January 1st of next year
const nextYear = result.find(date => date.getFullYear() === 2025)
expect(nextYear).toBeDefined()
if (nextYear) {
expect(nextYear.getMonth()).toBe(0) // January
expect(nextYear.getDate()).toBe(1)
}
})
})
describe('performance tests', () => {
test('performs well for complex expressions', () => {
const start = performance.now()
// Test multiple complex expressions
const expressions = [
'*/5 9-17 * * 1-5', // Every 5 minutes, weekdays, business hours
'0 */2 1,15 * *', // Every 2 hours on 1st and 15th
'30 14 * * 1,3,5', // Mon, Wed, Fri at 14:30
'15,45 8-18 * * 1-5', // 15 and 45 minutes past the hour, weekdays
]
expressions.forEach((expr) => {
const result = parseCronExpression(expr)
expect(result).toHaveLength(5)
})
// Test quarterly expression separately (may return fewer than 5 results)
const quarterlyResult = parseCronExpression('0 0 1 */3 *') // First day of every 3rd month
expect(quarterlyResult.length).toBeGreaterThan(0)
expect(quarterlyResult.length).toBeLessThanOrEqual(5)
const end = performance.now()
// Should complete within reasonable time (less than 100ms for all expressions)
expect(end - start).toBeLessThan(100)
})
})
})

View File

@ -0,0 +1,237 @@
const matchesField = (value: number, pattern: string, min: number, max: number): boolean => {
if (pattern === '*') return true
if (pattern.includes(','))
return pattern.split(',').some(p => matchesField(value, p.trim(), min, max))
if (pattern.includes('/')) {
const [range, step] = pattern.split('/')
const stepValue = Number.parseInt(step, 10)
if (Number.isNaN(stepValue)) return false
if (range === '*') {
return value % stepValue === min % stepValue
}
else {
const rangeStart = Number.parseInt(range, 10)
if (Number.isNaN(rangeStart)) return false
return value >= rangeStart && (value - rangeStart) % stepValue === 0
}
}
if (pattern.includes('-')) {
const [start, end] = pattern.split('-').map(p => Number.parseInt(p.trim(), 10))
if (Number.isNaN(start) || Number.isNaN(end)) return false
return value >= start && value <= end
}
const numValue = Number.parseInt(pattern, 10)
if (Number.isNaN(numValue)) return false
return value === numValue
}
const expandCronField = (field: string, min: number, max: number): number[] => {
if (field === '*')
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
if (field.includes(','))
return field.split(',').flatMap(p => expandCronField(p.trim(), min, max))
if (field.includes('/')) {
const [range, step] = field.split('/')
const stepValue = Number.parseInt(step, 10)
if (Number.isNaN(stepValue)) return []
const baseValues = range === '*' ? [min] : expandCronField(range, min, max)
const result: number[] = []
for (let start = baseValues[0]; start <= max; start += stepValue) {
if (start >= min && start <= max)
result.push(start)
}
return result
}
if (field.includes('-')) {
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
if (Number.isNaN(start) || Number.isNaN(end)) return []
const result: number[] = []
for (let i = start; i <= end && i <= max; i++)
if (i >= min) result.push(i)
return result
}
const numValue = Number.parseInt(field, 10)
return !Number.isNaN(numValue) && numValue >= min && numValue <= max ? [numValue] : []
}
const matchesCron = (
date: Date,
minute: string,
hour: string,
dayOfMonth: string,
month: string,
dayOfWeek: string,
): boolean => {
const currentMinute = date.getMinutes()
const currentHour = date.getHours()
const currentDay = date.getDate()
const currentMonth = date.getMonth() + 1
const currentDayOfWeek = date.getDay()
// Basic time matching
if (!matchesField(currentMinute, minute, 0, 59)) return false
if (!matchesField(currentHour, hour, 0, 23)) return false
if (!matchesField(currentMonth, month, 1, 12)) return false
// Day matching logic: if both dayOfMonth and dayOfWeek are specified (not *),
// the cron should match if EITHER condition is true (OR logic)
const dayOfMonthSpecified = dayOfMonth !== '*'
const dayOfWeekSpecified = dayOfWeek !== '*'
if (dayOfMonthSpecified && dayOfWeekSpecified) {
// If both are specified, match if either matches
return matchesField(currentDay, dayOfMonth, 1, 31)
|| matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
}
else if (dayOfMonthSpecified) {
// Only day of month specified
return matchesField(currentDay, dayOfMonth, 1, 31)
}
else if (dayOfWeekSpecified) {
// Only day of week specified
return matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
}
else {
// Both are *, matches any day
return true
}
}
export const parseCronExpression = (cronExpression: string): Date[] => {
if (!cronExpression || cronExpression.trim() === '')
return []
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5)
return []
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
try {
const nextTimes: Date[] = []
const now = new Date()
// Start from next minute
const startTime = new Date(now)
startTime.setMinutes(startTime.getMinutes() + 1)
startTime.setSeconds(0, 0)
// For monthly expressions (like "15 10 1 * *"), we need to check more months
// For weekly expressions, we need to check more weeks
// Use a smarter approach: check up to 12 months for monthly patterns
const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*'
const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*'
let searchMonths = 12
if (isWeeklyPattern) searchMonths = 3 // 3 months should cover 12+ weeks
else if (!isMonthlyPattern) searchMonths = 2 // For daily/hourly patterns
// Check across multiple months
for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) {
const checkMonth = new Date(startTime.getFullYear(), startTime.getMonth() + monthOffset, 1)
// Get the number of days in this month
const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate()
// Check each day in this month
for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) {
const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day)
// For each day, check the specific hour and minute from cron
// This is more efficient than checking all hours/minutes
if (minute !== '*' && hour !== '*') {
// Extract specific minute and hour values
const minuteValues = expandCronField(minute, 0, 59)
const hourValues = expandCronField(hour, 0, 23)
for (const h of hourValues) {
for (const m of minuteValues) {
checkDate.setHours(h, m, 0, 0)
// Skip if this time is before our start time
if (checkDate <= now) continue
if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
nextTimes.push(new Date(checkDate))
}
}
}
else {
// Fallback for complex expressions with wildcards
for (let h = 0; h < 24 && nextTimes.length < 5; h++) {
for (let m = 0; m < 60 && nextTimes.length < 5; m++) {
checkDate.setHours(h, m, 0, 0)
if (checkDate <= now) continue
if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
nextTimes.push(new Date(checkDate))
}
}
}
}
}
return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5)
}
catch {
return []
}
}
const isValidCronField = (field: string, min: number, max: number): boolean => {
if (field === '*') return true
if (field.includes(','))
return field.split(',').every(p => isValidCronField(p.trim(), min, max))
if (field.includes('/')) {
const [range, step] = field.split('/')
const stepValue = Number.parseInt(step, 10)
if (Number.isNaN(stepValue) || stepValue <= 0) return false
if (range === '*') return true
return isValidCronField(range, min, max)
}
if (field.includes('-')) {
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
if (Number.isNaN(start) || Number.isNaN(end)) return false
return start >= min && end <= max && start <= end
}
const numValue = Number.parseInt(field, 10)
return !Number.isNaN(numValue) && numValue >= min && numValue <= max
}
export const isValidCronExpression = (cronExpression: string): boolean => {
if (!cronExpression || cronExpression.trim() === '')
return false
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5)
return false
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
return (
isValidCronField(minute, 0, 59)
&& isValidCronField(hour, 0, 23)
&& isValidCronField(dayOfMonth, 1, 31)
&& isValidCronField(month, 1, 12)
&& isValidCronField(dayOfWeek, 0, 6)
)
}

View File

@ -0,0 +1,795 @@
import { formatExecutionTime, getDefaultDateTime, getFormattedExecutionTimes, getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
import type { ScheduleTriggerNodeType } from '../types'
const createMockData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
id: 'test-node',
type: 'schedule-trigger',
mode: 'visual',
frequency: 'daily',
visual_config: {
time: '11:30 AM',
weekdays: ['sun'],
recur_every: 1,
recur_unit: 'hours',
},
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Use system timezone for consistent tests
enabled: true,
...overrides,
})
describe('execution-time-calculator', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0)) // Local time: 2024-01-15 10:00:00
})
afterEach(() => {
jest.useRealTimers()
})
describe('formatExecutionTime', () => {
test('formats time with weekday by default', () => {
const date = new Date(2024, 0, 16, 14, 30)
const result = formatExecutionTime(date)
expect(result).toBe('Tue, January 16, 2024 2:30 PM')
})
test('formats time without weekday when specified', () => {
const date = new Date(2024, 0, 16, 14, 30)
const result = formatExecutionTime(date, false)
expect(result).toBe('January 16, 2024 2:30 PM')
})
test('handles morning times correctly', () => {
const date = new Date(2024, 0, 16, 9, 15)
const result = formatExecutionTime(date)
expect(result).toBe('Tue, January 16, 2024 9:15 AM')
})
test('handles midnight correctly', () => {
const date = new Date(2024, 0, 16, 0, 0)
const result = formatExecutionTime(date)
expect(result).toBe('Tue, January 16, 2024 12:00 AM')
})
})
describe('getNextExecutionTimes - daily frequency', () => {
test('calculates next 5 daily executions', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const result = getNextExecutionTimes(data, 5)
expect(result).toHaveLength(5)
expect(result[0].getHours()).toBe(14)
expect(result[0].getMinutes()).toBe(30)
expect(result[1].getDate()).toBe(result[0].getDate() + 1)
})
test('handles past time by moving to next day', () => {
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // 3:00 PM local time
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getDate()).toBe(16)
})
test('handles AM/PM conversion correctly', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '11:30 PM' },
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getHours()).toBe(23)
expect(result[0].getMinutes()).toBe(30)
})
test('handles 12 AM correctly', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '12:00 AM' },
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getHours()).toBe(0)
})
test('handles 12 PM correctly', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '12:00 PM' },
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getHours()).toBe(12)
})
})
describe('getNextExecutionTimes - weekly frequency', () => {
test('calculates next 5 weekly executions for Sunday', () => {
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '2:30 PM',
weekdays: ['sun'],
},
})
const result = getNextExecutionTimes(data, 5)
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getDay()).toBe(0)
expect(date.getHours()).toBe(14)
expect(date.getMinutes()).toBe(30)
})
})
test('calculates next execution for Monday from Monday', () => {
jest.setSystemTime(new Date(2024, 0, 15, 10, 0))
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '2:30 PM',
weekdays: ['mon'],
},
})
const result = getNextExecutionTimes(data, 2)
expect(result[0].getDate()).toBe(15)
expect(result[1].getDate()).toBe(22)
})
test('moves to next week when current day time has passed', () => {
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // Monday 3:00 PM local time
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '2:30 PM',
weekdays: ['mon'],
},
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getDate()).toBe(22)
})
test('handles different weekdays correctly', () => {
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '9:00 AM',
weekdays: ['fri'],
},
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getDay()).toBe(5)
})
})
describe('getNextExecutionTimes - hourly frequency', () => {
test('calculates hourly intervals correctly', () => {
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
const data = createMockData({
frequency: 'hourly',
visual_config: {
datetime: startTime.toISOString(),
recur_every: 2,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
expect(result[0].getTime() - startTime.getTime()).toBe(2 * 60 * 60 * 1000)
expect(result[1].getTime() - startTime.getTime()).toBe(4 * 60 * 60 * 1000)
expect(result[2].getTime() - startTime.getTime()).toBe(6 * 60 * 60 * 1000)
})
test('calculates minute intervals correctly', () => {
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
const data = createMockData({
frequency: 'hourly',
visual_config: {
datetime: startTime.toISOString(),
recur_every: 30,
recur_unit: 'minutes',
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
expect(result[0].getTime() - startTime.getTime()).toBe(30 * 60 * 1000)
expect(result[1].getTime() - startTime.getTime()).toBe(60 * 60 * 1000)
})
test('handles past start time by calculating next interval', () => {
jest.setSystemTime(new Date(2024, 0, 15, 14, 30, 0)) // Local time 2:30 PM
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
const data = createMockData({
frequency: 'hourly',
visual_config: {
datetime: startTime.toISOString(),
recur_every: 1,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result[0].getHours()).toBe(15)
expect(result[1].getHours()).toBe(16)
})
test('uses current time as default start time', () => {
const data = createMockData({
frequency: 'hourly',
visual_config: {
recur_every: 1,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 1)
expect(result[0].getTime()).toBeGreaterThan(Date.now())
})
test('minute intervals should not have duplicates when recur_every changes', () => {
const startTime = new Date(2024, 0, 15, 12, 0, 0)
// Test with recur_every = 2 minutes
const data2 = createMockData({
frequency: 'hourly',
visual_config: {
datetime: startTime.toISOString(),
recur_every: 2,
recur_unit: 'minutes',
},
})
const result2 = getNextExecutionTimes(data2, 5)
// Check for no duplicates in result2
const timestamps2 = result2.map(date => date.getTime())
const uniqueTimestamps2 = new Set(timestamps2)
expect(timestamps2.length).toBe(uniqueTimestamps2.size)
// Check intervals are correct for 2-minute intervals
for (let i = 1; i < result2.length; i++) {
const timeDiff = result2[i].getTime() - result2[i - 1].getTime()
expect(timeDiff).toBe(2 * 60 * 1000) // 2 minutes in milliseconds
}
})
test('hourly intervals should handle recur_every changes correctly', () => {
const startTime = new Date(2024, 0, 15, 12, 0, 0)
// Test with recur_every = 3 hours
const data = createMockData({
frequency: 'hourly',
visual_config: {
datetime: startTime.toISOString(),
recur_every: 3,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 4)
// Check for no duplicates
const timestamps = result.map(date => date.getTime())
const uniqueTimestamps = new Set(timestamps)
expect(timestamps.length).toBe(uniqueTimestamps.size)
// Check intervals are correct for 3-hour intervals
for (let i = 1; i < result.length; i++) {
const timeDiff = result[i].getTime() - result[i - 1].getTime()
expect(timeDiff).toBe(3 * 60 * 60 * 1000) // 3 hours in milliseconds
}
})
})
describe('getNextExecutionTimes - cron mode', () => {
test('uses cron parser for cron expressions', () => {
const data = createMockData({
mode: 'cron',
cron_expression: '0 12 * * *',
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
result.forEach((date) => {
expect(date.getHours()).toBe(12)
expect(date.getMinutes()).toBe(0)
})
})
test('returns empty array for invalid cron expression', () => {
const data = createMockData({
mode: 'cron',
cron_expression: 'invalid',
})
const result = getNextExecutionTimes(data, 5)
expect(result).toEqual([])
})
test('returns empty array for missing cron expression', () => {
const data = createMockData({
mode: 'cron',
cron_expression: '',
})
const result = getNextExecutionTimes(data, 5)
expect(result).toEqual([])
})
})
describe('getNextExecutionTimes - once frequency', () => {
test('returns selected datetime for once frequency', () => {
const selectedTime = new Date(2024, 0, 20, 15, 30, 0) // January 20, 2024 3:30 PM
const data = createMockData({
frequency: 'once',
visual_config: {
datetime: selectedTime.toISOString(),
},
})
const result = getNextExecutionTimes(data, 5)
expect(result).toHaveLength(1)
expect(result[0].getTime()).toBe(selectedTime.getTime())
})
test('returns empty array when no datetime selected for once frequency', () => {
const data = createMockData({
frequency: 'once',
visual_config: {},
})
const result = getNextExecutionTimes(data, 5)
expect(result).toEqual([])
})
})
describe('getNextExecutionTimes - fallback behavior', () => {
test('handles unknown frequency by returning next days', () => {
const data = createMockData({
frequency: 'unknown' as any,
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
expect(result[0].getDate()).toBe(16)
expect(result[1].getDate()).toBe(17)
expect(result[2].getDate()).toBe(18)
})
})
describe('getFormattedExecutionTimes', () => {
test('formats daily execution times without weekday', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const result = getFormattedExecutionTimes(data, 2)
expect(result).toHaveLength(2)
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
expect(result[0]).toMatch(/January \d+, 2024 2:30 PM/)
})
test('formats weekly execution times with weekday', () => {
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '2:30 PM',
weekdays: ['sun'],
},
})
const result = getFormattedExecutionTimes(data, 2)
expect(result).toHaveLength(2)
expect(result[0]).toMatch(/^Sun, January \d+, 2024 2:30 PM/)
})
test('formats hourly execution times without weekday', () => {
const data = createMockData({
frequency: 'hourly',
visual_config: {
datetime: new Date(2024, 0, 16, 14, 0, 0).toISOString(), // Local time 2:00 PM
recur_every: 2,
recur_unit: 'hours',
},
})
const result = getFormattedExecutionTimes(data, 1)
expect(result[0]).toMatch(/January 16, 2024 4:00 PM/)
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
})
test('returns empty array when no execution times', () => {
const data = createMockData({
mode: 'cron',
cron_expression: 'invalid',
})
const result = getFormattedExecutionTimes(data, 5)
expect(result).toEqual([])
})
})
describe('getNextExecutionTime', () => {
test('returns first formatted execution time', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const result = getNextExecutionTime(data)
expect(result).toMatch(/January \d+, 2024 2:30 PM/)
})
test('returns current time when no execution times available for non-once frequencies', () => {
const data = createMockData({
mode: 'cron',
cron_expression: 'invalid',
})
const result = getNextExecutionTime(data)
expect(result).toMatch(/January 15, 2024 10:00 AM/)
})
test('returns default datetime for once frequency when no datetime configured', () => {
const data = createMockData({
frequency: 'once',
visual_config: {},
})
const result = getNextExecutionTime(data)
expect(result).toMatch(/January 16, 2024 11:30 AM/)
})
test('returns configured datetime for once frequency when available', () => {
const selectedTime = new Date(2024, 0, 20, 15, 30, 0)
const data = createMockData({
frequency: 'once',
visual_config: {
datetime: selectedTime.toISOString(),
},
})
const result = getNextExecutionTime(data)
expect(result).toMatch(/January 20, 2024 3:30 PM/)
})
test('applies correct weekday formatting based on frequency', () => {
const weeklyData = createMockData({
frequency: 'weekly',
visual_config: {
time: '2:30 PM',
weekdays: ['sun'],
},
})
const dailyData = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const weeklyResult = getNextExecutionTime(weeklyData)
const dailyResult = getNextExecutionTime(dailyData)
expect(weeklyResult).toMatch(/^Sun,/)
expect(dailyResult).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
})
})
describe('edge cases and error handling', () => {
test('handles missing visual_config gracefully', () => {
const data = createMockData({
frequency: 'daily',
visual_config: undefined,
})
const result = getNextExecutionTimes(data, 1)
expect(result).toHaveLength(1)
})
test('uses default values for missing config properties', () => {
const data = createMockData({
frequency: 'hourly',
visual_config: {},
})
const result = getNextExecutionTimes(data, 1)
expect(result).toHaveLength(1)
})
test('handles malformed time strings gracefully', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: 'invalid time' },
})
expect(() => getNextExecutionTimes(data, 1)).not.toThrow()
})
test('returns reasonable defaults for zero count', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const result = getNextExecutionTimes(data, 0)
expect(result).toEqual([])
})
test('daily frequency should not have duplicate dates', () => {
const data = createMockData({
frequency: 'daily',
visual_config: { time: '2:30 PM' },
})
const result = getNextExecutionTimes(data, 5)
expect(result).toHaveLength(5)
// Check that each date is unique and consecutive
for (let i = 1; i < result.length; i++) {
const prevDate = result[i - 1].getDate()
const currDate = result[i].getDate()
expect(currDate).not.toBe(prevDate) // No duplicates
expect(currDate - prevDate).toBe(1) // Should be consecutive days
}
})
})
describe('getNextExecutionTimes - monthly frequency', () => {
test('returns monthly execution times for specific day', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '2:30 PM',
monthly_day: 15,
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
result.forEach((date) => {
expect(date.getDate()).toBe(15)
expect(date.getHours()).toBe(14)
expect(date.getMinutes()).toBe(30)
})
expect(result[0].getMonth()).toBe(0) // January
expect(result[1].getMonth()).toBe(1) // February
expect(result[2].getMonth()).toBe(2) // March
})
test('returns monthly execution times for last day', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '11:30 AM',
monthly_day: 'last',
},
})
const result = getNextExecutionTimes(data, 4)
expect(result).toHaveLength(4)
result.forEach((date) => {
expect(date.getHours()).toBe(11)
expect(date.getMinutes()).toBe(30)
})
expect(result[0].getDate()).toBe(31) // January 31
expect(result[1].getDate()).toBe(29) // February 29 (2024 is leap year)
expect(result[2].getDate()).toBe(31) // March 31
expect(result[3].getDate()).toBe(30) // April 30
})
test('handles day 31 in months with fewer days', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '3:00 PM',
monthly_day: 31,
},
})
const result = getNextExecutionTimes(data, 4)
expect(result).toHaveLength(4)
expect(result[0].getDate()).toBe(31) // January 31
expect(result[1].getDate()).toBe(29) // February 29 (can't have 31)
expect(result[2].getDate()).toBe(31) // March 31
expect(result[3].getDate()).toBe(30) // April 30 (can't have 31)
})
test('handles day 30 in February', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '9:00 AM',
monthly_day: 30,
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
expect(result[0].getDate()).toBe(30) // January 30
expect(result[1].getDate()).toBe(29) // February 29 (max in 2024)
expect(result[2].getDate()).toBe(30) // March 30
})
test('skips to next month if current month execution has passed', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2024, 0, 20, 15, 0, 0)) // January 20, 2024 3:00 PM
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '2:30 PM',
monthly_day: 15, // Already passed in January
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
expect(result[0].getMonth()).toBe(1) // February (skip January)
expect(result[1].getMonth()).toBe(2) // March
expect(result[2].getMonth()).toBe(3) // April
jest.useRealTimers()
})
test('includes current month if execution time has not passed', () => {
jest.useFakeTimers()
jest.setSystemTime(new Date(2024, 0, 10, 10, 0, 0)) // January 10, 2024 10:00 AM
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '2:30 PM',
monthly_day: 15, // Still upcoming in January
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(3)
expect(result[0].getMonth()).toBe(0) // January (current month)
expect(result[1].getMonth()).toBe(1) // February
expect(result[2].getMonth()).toBe(2) // March
jest.useRealTimers()
})
test('handles AM/PM time conversion correctly', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '11:30 PM',
monthly_day: 1,
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(2)
result.forEach((date) => {
expect(date.getHours()).toBe(23) // 11 PM in 24-hour format
expect(date.getMinutes()).toBe(30)
expect(date.getDate()).toBe(1)
})
})
test('formats monthly execution times without weekday', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '2:30 PM',
monthly_day: 15,
},
})
const result = getFormattedExecutionTimes(data, 1)
expect(result).toHaveLength(1)
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
expect(result[0]).toMatch(/January 15, 2024 2:30 PM/)
})
test('uses default day 1 when monthly_day is not specified', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '10:00 AM',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(2)
result.forEach((date) => {
expect(date.getDate()).toBe(1)
expect(date.getHours()).toBe(10)
expect(date.getMinutes()).toBe(0)
})
})
})
describe('getDefaultDateTime', () => {
test('returns consistent default datetime', () => {
const defaultDate = getDefaultDateTime()
expect(defaultDate.getHours()).toBe(11)
expect(defaultDate.getMinutes()).toBe(30)
expect(defaultDate.getSeconds()).toBe(0)
expect(defaultDate.getMilliseconds()).toBe(0)
expect(defaultDate.getDate()).toBe(new Date().getDate() + 1)
})
test('default datetime matches DateTimePicker fallback behavior', () => {
const data = createMockData({
frequency: 'once',
visual_config: {},
})
const nextExecutionTime = getNextExecutionTime(data)
const defaultDate = getDefaultDateTime()
const expectedFormat = formatExecutionTime(defaultDate, false)
expect(nextExecutionTime).toBe(expectedFormat)
})
})
})

View File

@ -0,0 +1,200 @@
import type { ScheduleTriggerNodeType } from '../types'
import { isValidCronExpression, parseCronExpression } from './cron-parser'
// Helper function to get current time - timezone is handled by Date object natively
const getCurrentTime = (): Date => {
return new Date()
}
// Helper function to get default datetime for once/hourly modes - consistent with DateTimePicker
export const getDefaultDateTime = (): Date => {
const defaultDate = new Date()
defaultDate.setHours(11, 30, 0, 0)
defaultDate.setDate(defaultDate.getDate() + 1)
return defaultDate
}
export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => {
if (data.mode === 'cron') {
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
return []
return parseCronExpression(data.cron_expression).slice(0, count)
}
const times: Date[] = []
const defaultTime = data.visual_config?.time || '11:30 AM'
if (data.frequency === 'hourly') {
const recurEvery = data.visual_config?.recur_every || 1
const recurUnit = data.visual_config?.recur_unit || 'hours'
const startTime = data.visual_config?.datetime ? new Date(data.visual_config.datetime) : getCurrentTime()
const intervalMs = recurUnit === 'hours'
? recurEvery * 60 * 60 * 1000
: recurEvery * 60 * 1000
// Calculate the initial offset if start time has passed
const now = getCurrentTime()
let initialOffset = 0
if (startTime <= now) {
const timeDiff = now.getTime() - startTime.getTime()
initialOffset = Math.floor(timeDiff / intervalMs)
}
for (let i = 0; i < count; i++) {
const nextExecution = new Date(startTime.getTime() + (initialOffset + i + 1) * intervalMs)
times.push(nextExecution)
}
}
else if (data.frequency === 'daily') {
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
const now = getCurrentTime()
const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
// Calculate initial offset: if time has passed today, start from tomorrow
const initialOffset = baseExecution <= now ? 1 : 0
for (let i = 0; i < count; i++) {
const nextExecution = new Date(baseExecution)
nextExecution.setDate(baseExecution.getDate() + initialOffset + i)
times.push(nextExecution)
}
}
else if (data.frequency === 'weekly') {
const selectedDay = data.visual_config?.weekdays?.[0] || 'sun'
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
const now = getCurrentTime()
const currentDay = now.getDay()
let daysUntilNext = (targetDay - currentDay + 7) % 7
const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
if (daysUntilNext === 0 && nextExecutionBase <= now)
daysUntilNext = 7
for (let i = 0; i < count; i++) {
const nextExecution = new Date(nextExecutionBase)
nextExecution.setDate(nextExecution.getDate() + daysUntilNext + (i * 7))
times.push(nextExecution)
}
}
else if (data.frequency === 'monthly') {
const selectedDay = data.visual_config?.monthly_day || 1
const [time, period] = defaultTime.split(' ')
const [hour, minute] = time.split(':')
let displayHour = Number.parseInt(hour)
if (period === 'PM' && displayHour !== 12) displayHour += 12
if (period === 'AM' && displayHour === 12) displayHour = 0
const now = getCurrentTime()
let monthOffset = 0
const currentMonthExecution = (() => {
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1)
let targetDay: number
if (selectedDay === 'last') {
const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
targetDay = lastDayOfMonth
}
else {
targetDay = Math.min(selectedDay as number, new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate())
}
return new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
})()
if (currentMonthExecution <= now)
monthOffset = 1
for (let i = 0; i < count; i++) {
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + i, 1)
let targetDay: number
if (selectedDay === 'last') {
const lastDayOfMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
targetDay = lastDayOfMonth
}
else {
targetDay = Math.min(selectedDay as number, new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate())
}
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
times.push(nextExecution)
}
}
else if (data.frequency === 'once') {
// For 'once' frequency, return the selected datetime
const selectedDateTime = data.visual_config?.datetime
if (selectedDateTime)
times.push(new Date(selectedDateTime))
}
else {
// Fallback for unknown frequencies
for (let i = 0; i < count; i++) {
const now = getCurrentTime()
const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1)
times.push(nextExecution)
}
}
return times
}
export const formatExecutionTime = (date: Date, includeWeekday: boolean = true): string => {
const dateOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
}
if (includeWeekday)
dateOptions.weekday = 'short'
const timeOptions: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}
// Always use local time for display to match calculation logic
return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
}
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
const times = getNextExecutionTimes(data, count)
return times.map((date) => {
// Only weekly frequency includes weekday in format
const includeWeekday = data.frequency === 'weekly'
return formatExecutionTime(date, includeWeekday)
})
}
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
const times = getFormattedExecutionTimes(data, 1)
if (times.length === 0) {
if (data.frequency === 'once') {
const defaultDate = getDefaultDateTime()
return formatExecutionTime(defaultDate, false)
}
const now = getCurrentTime()
const includeWeekday = data.frequency === 'weekly'
return formatExecutionTime(now, includeWeekday)
}
return times[0]
}

View File

@ -0,0 +1,31 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import type { WebhookTriggerNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
defaultValue: {
webhook_url: '',
http_methods: ['POST'],
authorization: {
type: 'none',
},
},
getAvailablePrevNodes(isChatMode: boolean) {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(payload: WebhookTriggerNodeType, t: any) {
return {
isValid: true,
errorMessage: '',
}
},
}
export default nodeDefault

View File

@ -0,0 +1,28 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { WebhookTriggerNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
const i18nPrefix = 'workflow.nodes.triggerWebhook'
const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({
data,
}) => {
const { t } = useTranslation()
return (
<div className="mb-1 px-3 py-1">
<div className="text-xs text-gray-700">
{t(`${i18nPrefix}.nodeTitle`)}
</div>
{data.http_methods && data.http_methods.length > 0 && (
<div className="text-xs text-gray-500">
{data.http_methods.join(', ')}
</div>
)}
</div>
)
}
export default React.memo(Node)

View File

@ -0,0 +1,29 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { WebhookTriggerNodeType } from './types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import type { NodePanelProps } from '@/app/components/workflow/types'
const i18nPrefix = 'workflow.nodes.triggerWebhook'
const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
return (
<div className='mt-2'>
<div className='space-y-4 px-4 pb-2'>
<Field title={t(`${i18nPrefix}.title`)}>
<div className="text-sm text-gray-500">
{t(`${i18nPrefix}.configPlaceholder`)}
</div>
</Field>
</div>
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,10 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
export type WebhookTriggerNodeType = CommonNodeType & {
webhook_url?: string
http_methods?: string[]
authorization?: {
type: 'none' | 'bearer' | 'api_key'
config?: Record<string, any>
}
}

View File

@ -13,6 +13,7 @@ import {
} from '../utils'
import {
useAvailableBlocks,
useIsChatMode,
useNodesReadOnly,
usePanelInteractions,
} from '../hooks'
@ -39,6 +40,7 @@ const AddBlock = ({
const { t } = useTranslation()
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const isChatMode = useIsChatMode()
const { nodesReadOnly } = useNodesReadOnly()
const { handlePaneContextmenuCancel } = usePanelInteractions()
const [open, setOpen] = useState(false)
@ -104,6 +106,7 @@ const AddBlock = ({
trigger={renderTrigger || renderTriggerElement}
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextBlocks}
showStartTab={!isChatMode}
/>
)
}

View File

@ -42,6 +42,9 @@ export enum BlockEnum {
Loop = 'loop',
LoopStart = 'loop-start',
LoopEnd = 'loop-end',
TriggerSchedule = 'trigger-schedule',
TriggerWebhook = 'trigger-webhook',
TriggerPlugin = 'trigger-plugin',
}
export enum ControlMode {
@ -453,3 +456,13 @@ export enum VersionHistoryContextMenuOptions {
export type ChildNodeTypeCount = {
[key: string]: number;
}
export const TRIGGER_NODE_TYPES = [
BlockEnum.TriggerSchedule,
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
] as const
export function isTriggerNode(nodeType: BlockEnum): boolean {
return TRIGGER_NODE_TYPES.includes(nodeType as any)
}

View File

@ -0,0 +1,26 @@
import { BlockEnum, type Node, isTriggerNode } from '../types'
/**
* Get the workflow entry node
* Priority: trigger nodes > start node
*/
export function getWorkflowEntryNode(nodes: Node[]): Node | undefined {
const triggerNode = nodes.find(node => isTriggerNode(node.data.type))
if (triggerNode) return triggerNode
return nodes.find(node => node.data.type === BlockEnum.Start)
}
/**
* Check if a node type is a workflow entry node
*/
export function isWorkflowEntryNode(nodeType: BlockEnum): boolean {
return nodeType === BlockEnum.Start || isTriggerNode(nodeType)
}
/**
* Check if workflow is in trigger mode
*/
export function isTriggerWorkflow(nodes: Node[]): boolean {
return nodes.some(node => isTriggerNode(node.data.type))
}

View File

@ -37,6 +37,7 @@ const translation = {
downloadFailed: 'Download failed. Please try again later.',
viewDetails: 'View Details',
delete: 'Delete',
now: 'Now',
deleteApp: 'Delete App',
settings: 'Settings',
setup: 'Setup',

View File

@ -222,6 +222,7 @@ const translation = {
},
tabs: {
'searchBlock': 'Search node',
'start': 'Start',
'blocks': 'Nodes',
'searchTool': 'Search tool',
'tools': 'Tools',
@ -261,6 +262,9 @@ const translation = {
'loop-start': 'Loop Start',
'loop': 'Loop',
'loop-end': 'Exit Loop',
'trigger-schedule': 'Schedule Trigger',
'trigger-webhook': 'Webhook Trigger',
'trigger-plugin': 'Plugin Trigger',
},
blocksAbout: {
'start': 'Define the initial parameters for launching a workflow',
@ -283,6 +287,9 @@ const translation = {
'document-extractor': 'Used to parse uploaded documents into text content that is easily understandable by LLM.',
'list-operator': 'Used to filter or sort array content.',
'agent': 'Invoking large language models to answer questions or process natural language',
'trigger-schedule': 'Time-based workflow trigger that starts workflows on a schedule',
'trigger-webhook': 'HTTP callback trigger that starts workflows from external HTTP requests',
'trigger-plugin': 'Third-party integration trigger that starts workflows from external platform events',
},
operator: {
zoomIn: 'Zoom In',
@ -917,6 +924,52 @@ const translation = {
clickToViewParameterSchema: 'Click to view parameter schema',
parameterSchema: 'Parameter Schema',
},
triggerSchedule: {
title: 'Schedule',
nodeTitle: 'Schedule Trigger',
notConfigured: 'Not configured',
useCronExpression: 'Use cron expression',
useVisualPicker: 'Use visual picker',
frequency: {
label: 'FREQUENCY',
hourly: 'Hourly',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
once: 'One time',
},
selectFrequency: 'Select frequency',
frequencyLabel: 'Frequency',
nextExecution: 'Next execution',
weekdays: 'Week days',
time: 'Time',
cronExpression: 'Cron expression',
nextExecutionTime: 'NEXT EXECUTION TIME',
nextExecutionTimes: 'Next 5 execution times',
startTime: 'Start Time',
executeNow: 'Execution now',
selectDateTime: 'Select Date & Time',
recurEvery: 'Recur every',
hours: 'Hours',
minutes: 'Minutes',
days: 'Days',
lastDay: 'Last day',
lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.',
},
triggerWebhook: {
title: 'Webhook Trigger',
nodeTitle: '🔗 Webhook Trigger',
configPlaceholder: 'Webhook trigger configuration will be implemented here',
},
triggerPlugin: {
title: 'Plugin Trigger',
nodeTitle: '🔌 Plugin Trigger',
configPlaceholder: 'Plugin trigger configuration will be implemented here',
},
},
triggerStatus: {
enabled: 'TRIGGER',
disabled: 'TRIGGER • DISABLED',
},
tracing: {
stopBy: 'Stop by {{user}}',

View File

@ -66,6 +66,7 @@ const translation = {
more: 'もっと',
selectAll: 'すべて選択',
deSelectAll: 'すべて選択解除',
now: '今',
},
errorMsg: {
fieldRequired: '{{field}}は必要です',

View File

@ -237,6 +237,7 @@ const translation = {
'agent': 'エージェント戦略',
'addAll': 'すべてを追加する',
'allAdded': 'すべて追加されました',
'start': '始める',
},
blocks: {
'start': '開始',
@ -261,6 +262,9 @@ const translation = {
'loop-start': 'ループ開始',
'loop': 'ループ',
'loop-end': 'ループ完了',
'trigger-plugin': 'プラグイントリガー',
'trigger-webhook': 'Webhook トリガー',
'trigger-schedule': 'スケジュールトリガー',
},
blocksAbout: {
'start': 'ワークフロー開始時の初期パラメータを定義します。',
@ -283,6 +287,9 @@ const translation = {
'document-extractor': 'アップロード文書を LLM 処理用に最適化されたテキストに変換します。',
'list-operator': '配列のフィルタリングやソート処理を行います。',
'agent': '大規模言語モデルを活用した質問応答や自然言語処理を実行します。',
'trigger-schedule': 'スケジュールに基づいてワークフローを開始する時間ベースのトリガー',
'trigger-webhook': 'HTTP コールバックトリガー、外部 HTTP リクエストによってワークフローを開始します',
'trigger-plugin': 'サードパーティ統合トリガー、外部プラットフォームのイベントによってワークフローを開始します',
},
operator: {
zoomIn: '拡大',
@ -917,6 +924,48 @@ const translation = {
parameterSchema: 'パラメータスキーマ',
clickToViewParameterSchema: 'パラメータースキーマを見るにはクリックしてください',
},
triggerSchedule: {
frequency: {
label: '頻度',
monthly: '毎月',
once: '1回のみ',
weekly: '毎週',
daily: '毎日',
hourly: '毎時',
},
frequencyLabel: '頻度',
days: '日',
title: 'スケジュール',
minutes: '分',
time: '時刻',
useCronExpression: 'Cron 式を使用',
nextExecutionTimes: '次の5回の実行時刻',
nextExecution: '次回実行',
notConfigured: '未設定',
startTime: '開始時刻',
hours: '時間',
executeNow: '今すぐ実行',
weekdays: '曜日',
selectDateTime: '日時を選択',
recurEvery: '間隔',
cronExpression: 'Cron 式',
selectFrequency: '頻度を選択',
lastDay: '月末',
nextExecutionTime: '次回実行時刻',
lastDayTooltip: 'すべての月に31日があるわけではありません。「月末」オプションを使用して各月の最終日を選択してください。',
useVisualPicker: 'ビジュアル設定を使用',
nodeTitle: 'スケジュールトリガー',
},
triggerWebhook: {
title: 'Webhook トリガー',
nodeTitle: '🔗 Webhook トリガー',
configPlaceholder: 'Webhook トリガーの設定がここに実装されます',
},
triggerPlugin: {
title: 'プラグイントリガー',
nodeTitle: '🔌 プラグイントリガー',
configPlaceholder: 'プラグイントリガーの設定がここに実装されます',
},
},
tracing: {
stopBy: '{{user}}によって停止',
@ -997,6 +1046,10 @@ const translation = {
copyLastRunError: '最後の実行の入力をコピーできませんでした',
noMatchingInputsFound: '前回の実行から一致する入力が見つかりませんでした。',
},
triggerStatus: {
enabled: 'トリガー',
disabled: 'トリガー • 無効',
},
}
export default translation

View File

@ -66,6 +66,7 @@ const translation = {
more: '更多',
selectAll: '全选',
deSelectAll: '取消全选',
now: '现在',
},
errorMsg: {
fieldRequired: '{{field}} 为必填项',

View File

@ -237,6 +237,7 @@ const translation = {
'agent': 'Agent 策略',
'allAdded': '已添加全部',
'addAll': '添加全部',
'start': '开始',
},
blocks: {
'start': '开始',
@ -261,6 +262,9 @@ const translation = {
'loop-start': '循环开始',
'loop': '循环',
'loop-end': '退出循环',
'trigger-webhook': 'Webhook 触发器',
'trigger-schedule': '定时触发器',
'trigger-plugin': '插件触发器',
},
blocksAbout: {
'start': '定义一个 workflow 流程启动的初始参数',
@ -283,6 +287,9 @@ const translation = {
'document-extractor': '用于将用户上传的文档解析为 LLM 便于理解的文本内容。',
'list-operator': '用于过滤或排序数组内容。',
'agent': '调用大型语言模型回答问题或处理自然语言',
'trigger-webhook': '从外部 HTTP 请求启动工作流的 HTTP 回调触发器',
'trigger-schedule': '基于时间的工作流触发器,按计划启动工作流',
'trigger-plugin': '从外部平台事件启动工作流的第三方集成触发器',
},
operator: {
zoomIn: '放大',
@ -917,6 +924,48 @@ const translation = {
clickToViewParameterSchema: '点击查看参数 schema',
parameterSchema: '参数 Schema',
},
triggerSchedule: {
frequency: {
label: '频率',
monthly: '每月',
once: '仅一次',
daily: '每日',
hourly: '每小时',
weekly: '每周',
},
title: '定时触发',
nodeTitle: '定时触发器',
useCronExpression: '使用 Cron 表达式',
selectFrequency: '选择频率',
nextExecutionTimes: '接下来 5 次执行时间',
hours: '小时',
recurEvery: '每隔',
minutes: '分钟',
cronExpression: 'Cron 表达式',
weekdays: '星期',
executeNow: '立即执行',
frequencyLabel: '频率',
nextExecution: '下次执行',
time: '时间',
lastDay: '最后一天',
startTime: '开始时间',
selectDateTime: '选择日期和时间',
lastDayTooltip: '并非所有月份都有 31 天。使用"最后一天"选项来选择每个月的最后一天。',
nextExecutionTime: '下次执行时间',
useVisualPicker: '使用可视化配置',
days: '天',
notConfigured: '未配置',
},
triggerWebhook: {
configPlaceholder: 'Webhook 触发器配置将在此处实现',
title: 'Webhook 触发器',
nodeTitle: '🔗 Webhook 触发器',
},
triggerPlugin: {
title: '插件触发器',
nodeTitle: '🔌 插件触发器',
configPlaceholder: '插件触发器配置将在此处实现',
},
},
tracing: {
stopBy: '由{{user}}终止',
@ -998,6 +1047,10 @@ const translation = {
noDependents: '无被依赖',
},
},
triggerStatus: {
enabled: '触发器',
disabled: '触发器 • 已禁用',
},
}
export default translation

View File

@ -434,7 +434,7 @@ html[data-theme="dark"] {
--color-workflow-block-bg: #27272b;
--color-workflow-block-bg-transparent: rgb(39 39 43 / 0.96);
--color-workflow-block-border-highlight: rgb(200 206 218 / 0.2);
--color-workflow-block-wrapper-bg-1: #27272b;
--color-workflow-block-wrapper-bg-1: #323236;
--color-workflow-block-wrapper-bg-2: rgb(39 39 43 / 0.2);
--color-workflow-canvas-workflow-dot-color: rgb(133 133 173 / 0.11);