diff --git a/web/app/components/base/select/index.tsx b/web/app/components/base/select/index.tsx index aa0cf02215..9ad2a489b0 100644 --- a/web/app/components/base/select/index.tsx +++ b/web/app/components/base/select/index.tsx @@ -26,6 +26,8 @@ const defaultItems = [ export type Item = { value: number | string name: string + isGroup?: boolean + disabled?: boolean } & Record export type ISelectProps = { @@ -255,38 +257,47 @@ const SimpleSelect: FC = ({ {(!disabled) && ( - {items.map((item: Item) => ( - - {({ /* active, */ selected }) => ( - <> - {renderOption - ? renderOption({ item, selected }) - : (<> - {item.name} - {selected && !hideChecked && ( - - - )} - )} - - )} - - ))} + {items.map((item: Item) => + item.isGroup ? ( +
+ {item.name} +
+ ) : ( + + {({ /* active, */ selected }) => ( + <> + {renderOption + ? renderOption({ item, selected }) + : (<> + {item.name} + {selected && !hideChecked && ( + + + )} + )} + + )} + + ), + )}
)} diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index 680cbf45b9..9c429edba8 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -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, diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index d00815584d..29d54f12f4 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -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) => { diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 0673ca0c0d..96433b37ef 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -43,6 +43,7 @@ type NodeSelectorProps = { availableBlocksTypes?: BlockEnum[] disabled?: boolean noBlocks?: boolean + showStartTab?: boolean } const NodeSelector: FC = ({ open: openFromProps, @@ -59,6 +60,7 @@ const NodeSelector: FC = ({ availableBlocksTypes, disabled, noBlocks = false, + showStartTab = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') @@ -90,9 +92,10 @@ const NodeSelector: FC = ({ 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 = ({ tags={tags} availableBlocksTypes={availableBlocksTypes} noBlocks={noBlocks} + showStartTab={showStartTab} /> diff --git a/web/app/components/workflow/block-selector/start-blocks.tsx b/web/app/components/workflow/block-selector/start-blocks.tsx new file mode 100644 index 0000000000..b8e9f3185a --- /dev/null +++ b/web/app/components/workflow/block-selector/start-blocks.tsx @@ -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]) => ( + + +
{block.title}
+
{nodesExtraData[block.type].about}
+ + )} + > +
onSelect(block.type)} + > + +
{block.title}
+
+
+ ), [nodesExtraData, onSelect]) + + return ( +
+ {isEmpty && ( +
+ {t('workflow.tabs.noResult')} +
+ )} + {!isEmpty && ( +
+ {filteredBlocks.map(renderBlock)} +
+ )} +
+ ) +} + +export default memo(StartBlocks) diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx index 3f3fed2ca9..9c6e4253f4 100644 --- a/web/app/components/workflow/block-selector/tabs.tsx +++ b/web/app/components/workflow/block-selector/tabs.tsx @@ -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 = ({ activeTab, @@ -28,8 +30,9 @@ const Tabs: FC = ({ 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 = ({ ) } {filterElem} + { + activeTab === TabsEnum.Start && !noBlocks && ( +
+ +
+ ) + } { activeTab === TabsEnum.Blocks && !noBlocks && (
diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 361d4ccc9d..a4d358525e 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -1,6 +1,7 @@ import type { PluginMeta } from '../../plugins/types' export enum TabsEnum { + Start = 'start', Blocks = 'blocks', Tools = 'tools', } diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 5bf053e2c5..9728e44b05 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -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 = { 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 diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 387567da0a..0aaaad689a 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -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 { diff --git a/web/app/components/workflow/nodes/_base/components/trigger-container.tsx b/web/app/components/workflow/nodes/_base/components/trigger-container.tsx new file mode 100644 index 0000000000..97853126c0 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/trigger-container.tsx @@ -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 = ({ + 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 ( +
+
+
+ + {statusConfig.label} + +
+ {children} +
+ ) +} + +export default TriggerContainer diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index c2600fd035..233b0af42e 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -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 = ({ return null }, [data._loopIndex, data._runningStatus, t]) - return ( + const isTriggerNode = TRIGGER_NODE_TYPES.includes(data.type as any) + + const nodeContent = (
= ({
{ 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) && (
- {cloneElement(children, { id, data })} + {cloneElement(children, { data } as any)}
) } @@ -332,6 +336,12 @@ const BaseNode: FC = ({
) + + return isTriggerNode ? ( + + {nodeContent} + + ) : nodeContent } export default memo(BaseNode) diff --git a/web/app/components/workflow/nodes/constants.ts b/web/app/components/workflow/nodes/constants.ts index 0cd6922233..ee2fc7a459 100644 --- a/web/app/components/workflow/nodes/constants.ts +++ b/web/app/components/workflow/nodes/constants.ts @@ -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> = { @@ -61,6 +67,9 @@ export const NodeComponentMap: Record> = { [BlockEnum.DocExtractor]: DocExtractorNode, [BlockEnum.ListFilter]: ListFilterNode, [BlockEnum.Agent]: AgentNode, + [BlockEnum.TriggerSchedule]: TriggerScheduleNode, + [BlockEnum.TriggerWebhook]: TriggerWebhookNode, + [BlockEnum.TriggerPlugin]: TriggerPluginNode, } export const PanelComponentMap: Record> = { @@ -84,6 +93,9 @@ export const PanelComponentMap: Record> = { [BlockEnum.DocExtractor]: DocExtractorPanel, [BlockEnum.ListFilter]: ListFilterPanel, [BlockEnum.Agent]: AgentPanel, + [BlockEnum.TriggerSchedule]: TriggerSchedulePanel, + [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, + [BlockEnum.TriggerPlugin]: TriggerPluginPanel, } export const CUSTOM_NODE_TYPE = 'custom' diff --git a/web/app/components/workflow/nodes/trigger-plugin/default.ts b/web/app/components/workflow/nodes/trigger-plugin/default.ts new file mode 100644 index 0000000000..3c7103e499 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/default.ts @@ -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 = { + 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 diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx new file mode 100644 index 0000000000..03b8010cce --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -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> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {t(`${i18nPrefix}.nodeTitle`)} +
+ {data.plugin_name && ( +
+ {data.plugin_name} +
+ )} +
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-plugin/panel.tsx b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx new file mode 100644 index 0000000000..7913d06415 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/panel.tsx @@ -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> = ({ + id, + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+ {t(`${i18nPrefix}.configPlaceholder`)} +
+
+
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-plugin/types.ts b/web/app/components/workflow/nodes/trigger-plugin/types.ts new file mode 100644 index 0000000000..b61f32ea4f --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/types.ts @@ -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 +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx new file mode 100644 index 0000000000..4d5a55029a --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.spec.tsx @@ -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 = { + '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() + + 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() + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + test('opens picker when button is clicked', () => { + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + const button = screen.getByRole('button') + expect(button.textContent).toMatch(/January 15, 2024/) + expect(button.textContent).toMatch(/\d{1,2}:30 [AP]M/) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx new file mode 100644 index 0000000000..5c8dffadd0 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/date-time-picker.tsx @@ -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) => { + 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 ( +
+ + + {isOpen && ( +
+
+

{t('workflow.nodes.triggerSchedule.selectDateTime')}

+
+ +
+ +
+ +
+ +
+ + +
+
+ )} + + {isOpen && ( +
{ + setTempValue('') + setIsOpen(false) + }} + /> + )} +
+ ) +} + +export default DateTimePicker diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx new file mode 100644 index 0000000000..8acf252c05 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/execute-now-button.tsx @@ -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 ( + + ) +} + +export default ExecuteNowButton diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx new file mode 100644 index 0000000000..fa48a66350 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/frequency-selector.tsx @@ -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 ( + 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 diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx new file mode 100644 index 0000000000..6dc88c85bf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx @@ -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 ( + + ) +} + +export default ModeSwitcher diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx new file mode 100644 index 0000000000..33c03765d0 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/mode-toggle.tsx @@ -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 ( + + ) +} + +export default ModeToggle diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx new file mode 100644 index 0000000000..4c9c8b75b6 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -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 ( +
+ + +
+ {rows.map((row, rowIndex) => ( +
+ {row.map(day => ( + + ))} + {/* 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) => ( +
+ ))} +
+ ))} +
+
+ ) +} + +export default MonthlyDaysSelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx new file mode 100644 index 0000000000..2c1c790af7 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/next-execution-times.tsx @@ -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 ( +
+ +
+ {executionTimes.map((time, index) => ( +
+ + {String(index + 1).padStart(2, '0')} + + {time} +
+ ))} +
+
+ ) +} + +export default NextExecutionTimes diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx new file mode 100644 index 0000000000..ffbd38aa0e --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/recur-config.tsx @@ -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 ( +
+
+ + onRecurEveryChange(value || 1)} + min={1} + className="text-center" + /> +
+
+ + +
+
+ ) +} + +export default RecurConfig diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx new file mode 100644 index 0000000000..695e62fd90 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/simple-segmented-control.tsx @@ -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 = { + options: { text: string, value: T }[] + value: T + onChange: (value: T) => void + className?: string +} + +export const SimpleSegmentedControl = ({ + options, + value, + onChange, + className, +}: SimpleSegmentedControlProps) => { + const selectedOptionIndex = options.findIndex(option => option.value === value) + + return ( +
+ {options.map((option, index) => { + const isSelected = index === selectedOptionIndex + const isNextSelected = index === selectedOptionIndex - 1 + const isLast = index === options.length - 1 + return ( + + ) + })} +
+ ) +} + +export default React.memo(SimpleSegmentedControl) as typeof SimpleSegmentedControl diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx new file mode 100644 index 0000000000..2464c63ba5 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.spec.tsx @@ -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 = { + '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() + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button.textContent).toBe('11:30 AM') + }) + + test('renders with provided value', () => { + render() + + const button = screen.getByRole('button') + expect(button.textContent).toBe('2:30 PM') + }) + + test('opens picker when button is clicked', () => { + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + const button = screen.getByRole('button') + fireEvent.click(button) + + await waitFor(() => { + expect(mockScrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }) + }, { timeout: 200 }) + + mockScrollIntoView.mockRestore() + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx new file mode 100644 index 0000000000..cacdbb01b8 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/time-picker.tsx @@ -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(null) + const minuteContainerRef = useRef(null) + const periodContainerRef = useRef(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) => ( +
+ )) + + 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 ( +
+ + + {isOpen && ( +
+
+

{t('time.title.pickTime')}

+
+ +
+ +
+ {/* Hours */} +
+
+ {hours.map(hour => ( + + ))} + {createBottomPadding()} +
+
+ + {/* Minutes */} +
+
+ {minutes.map(minute => ( + + ))} + {createBottomPadding()} +
+
+ + {/* AM/PM */} +
+
+ {periods.map(period => ( + + ))} + {createBottomPadding()} +
+
+
+ + {/* Divider */} +
+ + {/* Buttons */} +
+ + +
+
+ )} + + {isOpen && ( +
setIsOpen(false)} + /> + )} +
+ ) +} + +export default TimePicker diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx new file mode 100644 index 0000000000..03a36074bd --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/components/weekday-selector.tsx @@ -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 ( +
+ +
+ {weekdays.map(day => ( + + ))} +
+
+ ) +} + +export default WeekdaySelector diff --git a/web/app/components/workflow/nodes/trigger-schedule/default.ts b/web/app/components/workflow/nodes/trigger-schedule/default.ts new file mode 100644 index 0000000000..3a9ead48a9 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/default.ts @@ -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 = { + 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 diff --git a/web/app/components/workflow/nodes/trigger-schedule/node.tsx b/web/app/components/workflow/nodes/trigger-schedule/node.tsx new file mode 100644 index 0000000000..e9c1f34e2c --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/node.tsx @@ -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> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {t(`${i18nPrefix}.nextExecutionTime`)} +
+
+ {getNextExecutionTime(data)} +
+
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-schedule/panel.tsx b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx new file mode 100644 index 0000000000..6465b47afd --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/panel.tsx @@ -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> = ({ + 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 ( +
+
+ + } + > +
+ + {inputs.mode === 'visual' && ( +
+
+
+ + +
+
+ + {inputs.frequency === 'hourly' || inputs.frequency === 'once' ? ( + { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + datetime, + }, + } + setInputs(newInputs) + }} + /> + ) : ( + + )} +
+
+ + {inputs.frequency === 'weekly' && ( + + )} + + {inputs.frequency === 'hourly' && ( + + )} + + {inputs.frequency === 'monthly' && ( + { + const newInputs = { + ...inputs, + visual_config: { + ...inputs.visual_config, + monthly_day: day, + }, + } + setInputs(newInputs) + }} + /> + )} +
+ )} + + {inputs.mode === 'cron' && ( +
+
+ + handleCronExpressionChange(e.target.value)} + placeholder="0 0 * * *" + className="font-mono" + /> +
+
+ Enter cron expression (minute hour day month weekday) +
+
+ )} +
+
+ +
+ + + +
+ +
+
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-schedule/types.ts b/web/app/components/workflow/nodes/trigger-schedule/types.ts new file mode 100644 index 0000000000..748d3ab96e --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/types.ts @@ -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 +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts new file mode 100644 index 0000000000..1bc29b18b1 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -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(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 diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts new file mode 100644 index 0000000000..b6545f39c5 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.spec.ts @@ -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) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts new file mode 100644 index 0000000000..e4316ecbd2 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -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) + ) +} diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts new file mode 100644 index 0000000000..3e05c96255 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.spec.ts @@ -0,0 +1,795 @@ +import { formatExecutionTime, getDefaultDateTime, getFormattedExecutionTimes, getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator' +import type { ScheduleTriggerNodeType } from '../types' + +const createMockData = (overrides: Partial = {}): 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) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts new file mode 100644 index 0000000000..cbb118ecc6 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator.ts @@ -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] +} diff --git a/web/app/components/workflow/nodes/trigger-webhook/default.ts b/web/app/components/workflow/nodes/trigger-webhook/default.ts new file mode 100644 index 0000000000..104f9fb0b2 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/default.ts @@ -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 = { + 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 diff --git a/web/app/components/workflow/nodes/trigger-webhook/node.tsx b/web/app/components/workflow/nodes/trigger-webhook/node.tsx new file mode 100644 index 0000000000..bb6fd48283 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/node.tsx @@ -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> = ({ + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {t(`${i18nPrefix}.nodeTitle`)} +
+ {data.http_methods && data.http_methods.length > 0 && ( +
+ {data.http_methods.join(', ')} +
+ )} +
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/trigger-webhook/panel.tsx b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx new file mode 100644 index 0000000000..ff2ef5cec9 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/panel.tsx @@ -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> = ({ + id, + data, +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+ {t(`${i18nPrefix}.configPlaceholder`)} +
+
+
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/trigger-webhook/types.ts b/web/app/components/workflow/nodes/trigger-webhook/types.ts new file mode 100644 index 0000000000..8a118e7086 --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-webhook/types.ts @@ -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 + } +} diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 5bc541a45a..5d5e26d503 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -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} /> ) } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 61ebdb64a2..b68c4771a1 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -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) +} diff --git a/web/app/components/workflow/utils/workflow-entry.ts b/web/app/components/workflow/utils/workflow-entry.ts new file mode 100644 index 0000000000..724a68a85b --- /dev/null +++ b/web/app/components/workflow/utils/workflow-entry.ts @@ -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)) +} diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index d9b17dcbad..dd538d9c98 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -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', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 91a1ff3ba4..d24f2905e3 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -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}}', diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index d0a6b64d6e..73de8896df 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -66,6 +66,7 @@ const translation = { more: 'もっと', selectAll: 'すべて選択', deSelectAll: 'すべて選択解除', + now: '今', }, errorMsg: { fieldRequired: '{{field}}は必要です', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index a1e4b92482..7bbd8eeea5 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -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 diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index e51b84c37e..f1378b1816 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -66,6 +66,7 @@ const translation = { more: '更多', selectAll: '全选', deSelectAll: '取消全选', + now: '现在', }, errorMsg: { fieldRequired: '{{field}} 为必填项', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 5b56f112c5..85dc8065d9 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -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 diff --git a/web/themes/dark.css b/web/themes/dark.css index 9b9d467b08..16a6441140 100644 --- a/web/themes/dark.css +++ b/web/themes/dark.css @@ -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);