mirror of https://github.com/langgenius/dify.git
feat: comprehensive trigger node system with Schedule Trigger implementation (#24039)
Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
This commit is contained in:
parent
f214eeb7b1
commit
74ad21b145
|
|
@ -26,6 +26,8 @@ const defaultItems = [
|
|||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
isGroup?: boolean
|
||||
disabled?: boolean
|
||||
} & Record<string, any>
|
||||
|
||||
export type ISelectProps = {
|
||||
|
|
@ -255,38 +257,47 @@ const SimpleSelect: FC<ISelectProps> = ({
|
|||
|
||||
{(!disabled) && (
|
||||
<ListboxOptions className={classNames('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm', optionWrapClassName)}>
|
||||
{items.map((item: Item) => (
|
||||
<ListboxOption
|
||||
key={item.value}
|
||||
className={
|
||||
classNames(
|
||||
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
|
||||
optionClassName,
|
||||
)
|
||||
}
|
||||
value={item}
|
||||
disabled={disabled}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
{renderOption
|
||||
? renderOption({ item, selected })
|
||||
: (<>
|
||||
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && !hideChecked && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
{items.map((item: Item) =>
|
||||
item.isGroup ? (
|
||||
<div
|
||||
key={item.value}
|
||||
className="select-none px-3 py-1.5 text-xs font-medium uppercase tracking-wide text-text-tertiary"
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
) : (
|
||||
<ListboxOption
|
||||
key={item.value}
|
||||
className={
|
||||
classNames(
|
||||
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
|
||||
optionClassName,
|
||||
)
|
||||
}
|
||||
value={item}
|
||||
disabled={item.disabled || disabled}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
{renderOption
|
||||
? renderOption({ item, selected })
|
||||
: (<>
|
||||
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && !hideChecked && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
),
|
||||
)}
|
||||
</ListboxOptions>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type NodeSelectorProps = {
|
|||
availableBlocksTypes?: BlockEnum[]
|
||||
disabled?: boolean
|
||||
noBlocks?: boolean
|
||||
showStartTab?: boolean
|
||||
}
|
||||
const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
open: openFromProps,
|
||||
|
|
@ -59,6 +60,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||
availableBlocksTypes,
|
||||
disabled,
|
||||
noBlocks = false,
|
||||
showStartTab = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
|
@ -90,9 +92,10 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||
setActiveTab(newActiveTab)
|
||||
}, [])
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start)
|
||||
return t('workflow.tabs.searchBlock')
|
||||
if (activeTab === TabsEnum.Blocks)
|
||||
return t('workflow.tabs.searchBlock')
|
||||
|
||||
if (activeTab === TabsEnum.Tools)
|
||||
return t('workflow.tabs.searchTool')
|
||||
return ''
|
||||
|
|
@ -163,6 +166,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||
tags={tags}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
noBlocks={noBlocks}
|
||||
showStartTab={showStartTab}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BlockIcon from '../block-icon'
|
||||
import type { BlockEnum } from '../types'
|
||||
import { useNodesExtraData } from '../hooks'
|
||||
import { START_BLOCKS } from './constants'
|
||||
import type { ToolDefaultValue } from './types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type StartBlocksProps = {
|
||||
searchText: string
|
||||
onSelect: (type: BlockEnum, tool?: ToolDefaultValue) => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
}
|
||||
|
||||
const StartBlocks = ({
|
||||
searchText,
|
||||
onSelect,
|
||||
availableBlocksTypes = [],
|
||||
}: StartBlocksProps) => {
|
||||
const { t } = useTranslation()
|
||||
const nodesExtraData = useNodesExtraData()
|
||||
|
||||
const filteredBlocks = useMemo(() => {
|
||||
return START_BLOCKS.filter((block) => {
|
||||
return block.title.toLowerCase().includes(searchText.toLowerCase())
|
||||
&& availableBlocksTypes.includes(block.type)
|
||||
})
|
||||
}, [searchText, availableBlocksTypes])
|
||||
|
||||
const isEmpty = filteredBlocks.length === 0
|
||||
|
||||
const renderBlock = useCallback((block: typeof START_BLOCKS[0]) => (
|
||||
<Tooltip
|
||||
key={block.type}
|
||||
position='right'
|
||||
popupClassName='w-[200px]'
|
||||
needsDelay={false}
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='system-md-medium mb-1 text-text-primary'>{block.title}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{nodesExtraData[block.type].about}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={() => onSelect(block.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className='mr-2 shrink-0'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='grow text-sm text-text-secondary'>{block.title}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
), [nodesExtraData, onSelect])
|
||||
|
||||
return (
|
||||
<div className='p-1'>
|
||||
{isEmpty && (
|
||||
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>
|
||||
{t('workflow.tabs.noResult')}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<div className='mb-1'>
|
||||
{filteredBlocks.map(renderBlock)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StartBlocks)
|
||||
|
|
@ -6,6 +6,7 @@ import { useTabs } from './hooks'
|
|||
import type { ToolDefaultValue } from './types'
|
||||
import { TabsEnum } from './types'
|
||||
import Blocks from './blocks'
|
||||
import StartBlocks from './start-blocks'
|
||||
import AllTools from './all-tools'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export type TabsProps = {
|
|||
availableBlocksTypes?: BlockEnum[]
|
||||
filterElem: React.ReactNode
|
||||
noBlocks?: boolean
|
||||
showStartTab?: boolean
|
||||
}
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
|
|
@ -28,8 +30,9 @@ const Tabs: FC<TabsProps> = ({
|
|||
availableBlocksTypes,
|
||||
filterElem,
|
||||
noBlocks,
|
||||
showStartTab = false,
|
||||
}) => {
|
||||
const tabs = useTabs()
|
||||
const tabs = useTabs(showStartTab)
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
|
|
@ -60,6 +63,17 @@ const Tabs: FC<TabsProps> = ({
|
|||
)
|
||||
}
|
||||
{filterElem}
|
||||
{
|
||||
activeTab === TabsEnum.Start && !noBlocks && (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
<StartBlocks
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeTab === TabsEnum.Blocks && !noBlocks && (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { PluginMeta } from '../../plugins/types'
|
||||
|
||||
export enum TabsEnum {
|
||||
Start = 'start',
|
||||
Blocks = 'blocks',
|
||||
Tools = 'tools',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import IterationStartDefault from './nodes/iteration-start/default'
|
|||
import AgentDefault from './nodes/agent/default'
|
||||
import LoopStartDefault from './nodes/loop-start/default'
|
||||
import LoopEndDefault from './nodes/loop-end/default'
|
||||
import TriggerScheduleDefault from './nodes/trigger-schedule/default'
|
||||
import TriggerWebhookDefault from './nodes/trigger-webhook/default'
|
||||
import TriggerPluginDefault from './nodes/trigger-plugin/default'
|
||||
|
||||
type NodesExtraData = {
|
||||
author: string
|
||||
|
|
@ -242,6 +245,33 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
|
|||
getAvailableNextNodes: ListFilterDefault.getAvailableNextNodes,
|
||||
checkValid: AgentDefault.checkValid,
|
||||
},
|
||||
[BlockEnum.TriggerSchedule]: {
|
||||
author: 'Dify',
|
||||
about: '',
|
||||
availablePrevNodes: [],
|
||||
availableNextNodes: [],
|
||||
getAvailablePrevNodes: TriggerScheduleDefault.getAvailablePrevNodes,
|
||||
getAvailableNextNodes: TriggerScheduleDefault.getAvailableNextNodes,
|
||||
checkValid: TriggerScheduleDefault.checkValid,
|
||||
},
|
||||
[BlockEnum.TriggerWebhook]: {
|
||||
author: 'Dify',
|
||||
about: '',
|
||||
availablePrevNodes: [],
|
||||
availableNextNodes: [],
|
||||
getAvailablePrevNodes: TriggerWebhookDefault.getAvailablePrevNodes,
|
||||
getAvailableNextNodes: TriggerWebhookDefault.getAvailableNextNodes,
|
||||
checkValid: TriggerWebhookDefault.checkValid,
|
||||
},
|
||||
[BlockEnum.TriggerPlugin]: {
|
||||
author: 'Dify',
|
||||
about: '',
|
||||
availablePrevNodes: [],
|
||||
availableNextNodes: [],
|
||||
getAvailablePrevNodes: TriggerPluginDefault.getAvailablePrevNodes,
|
||||
getAvailableNextNodes: TriggerPluginDefault.getAvailableNextNodes,
|
||||
checkValid: TriggerPluginDefault.checkValid,
|
||||
},
|
||||
}
|
||||
|
||||
export const NODES_INITIAL_DATA = {
|
||||
|
|
@ -401,6 +431,24 @@ export const NODES_INITIAL_DATA = {
|
|||
desc: '',
|
||||
...AgentDefault.defaultValue,
|
||||
},
|
||||
[BlockEnum.TriggerSchedule]: {
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
title: '',
|
||||
desc: '',
|
||||
...TriggerScheduleDefault.defaultValue,
|
||||
},
|
||||
[BlockEnum.TriggerWebhook]: {
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
title: '',
|
||||
desc: '',
|
||||
...TriggerWebhookDefault.defaultValue,
|
||||
},
|
||||
[BlockEnum.TriggerPlugin]: {
|
||||
type: BlockEnum.TriggerPlugin,
|
||||
title: '',
|
||||
desc: '',
|
||||
...TriggerPluginDefault.defaultValue,
|
||||
},
|
||||
}
|
||||
export const MAX_ITERATION_PARALLEL_NUM = 10
|
||||
export const MIN_ITERATION_PARALLEL_NUM = 1
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import type { FC, ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type TriggerStatus = 'enabled' | 'disabled'
|
||||
|
||||
type TriggerContainerProps = {
|
||||
children: ReactNode
|
||||
status?: TriggerStatus
|
||||
customLabel?: string
|
||||
}
|
||||
|
||||
const TriggerContainer: FC<TriggerContainerProps> = ({
|
||||
children,
|
||||
status = 'enabled',
|
||||
customLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const statusConfig = useMemo(() => {
|
||||
const isDisabled = status === 'disabled'
|
||||
|
||||
return {
|
||||
label: customLabel || (isDisabled ? t('workflow.triggerStatus.disabled') : t('workflow.triggerStatus.enabled')),
|
||||
dotColor: isDisabled ? 'bg-text-tertiary' : 'bg-green-500',
|
||||
}
|
||||
}, [status, customLabel, t])
|
||||
|
||||
return (
|
||||
<div className="w-[242px] rounded-2xl bg-workflow-block-wrapper-bg-1 px-px pb-px pt-0.5">
|
||||
<div className="mb-0.5 flex items-center px-2 pt-1">
|
||||
<div className={cn('mr-1 h-2 w-2 rounded-sm', statusConfig.dotColor)} />
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TriggerContainer
|
||||
|
|
@ -20,6 +20,7 @@ import type { NodeProps } from '../../types'
|
|||
import {
|
||||
BlockEnum,
|
||||
NodeRunningStatus,
|
||||
TRIGGER_NODE_TYPES,
|
||||
} from '../../types'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
|
|
@ -42,6 +43,7 @@ import NodeControl from './components/node-control'
|
|||
import ErrorHandleOnNode from './components/error-handle/error-handle-on-node'
|
||||
import RetryOnNode from './components/retry/retry-on-node'
|
||||
import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
|
||||
import TriggerContainer from './components/trigger-container'
|
||||
import cn from '@/utils/classnames'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
|
@ -136,7 +138,9 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
return null
|
||||
}, [data._loopIndex, data._runningStatus, t])
|
||||
|
||||
return (
|
||||
const isTriggerNode = TRIGGER_NODE_TYPES.includes(data.type as any)
|
||||
|
||||
const nodeContent = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-2xl border-[2px]',
|
||||
|
|
@ -291,13 +295,13 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
</div>
|
||||
{
|
||||
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
|
||||
cloneElement(children, { id, data })
|
||||
cloneElement(children, { data } as any)
|
||||
)
|
||||
}
|
||||
{
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
|
||||
<div className='grow pb-1 pl-1 pr-1'>
|
||||
{cloneElement(children, { id, data })}
|
||||
{cloneElement(children, { data } as any)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -332,6 +336,12 @@ const BaseNode: FC<BaseNodeProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return isTriggerNode ? (
|
||||
<TriggerContainer status="enabled">
|
||||
{nodeContent}
|
||||
</TriggerContainer>
|
||||
) : nodeContent
|
||||
}
|
||||
|
||||
export default memo(BaseNode)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ import ListFilterNode from './list-operator/node'
|
|||
import ListFilterPanel from './list-operator/panel'
|
||||
import AgentNode from './agent/node'
|
||||
import AgentPanel from './agent/panel'
|
||||
import TriggerScheduleNode from './trigger-schedule/node'
|
||||
import TriggerSchedulePanel from './trigger-schedule/panel'
|
||||
import TriggerWebhookNode from './trigger-webhook/node'
|
||||
import TriggerWebhookPanel from './trigger-webhook/panel'
|
||||
import TriggerPluginNode from './trigger-plugin/node'
|
||||
import TriggerPluginPanel from './trigger-plugin/panel'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const NodeComponentMap: Record<string, ComponentType<any>> = {
|
||||
|
|
@ -61,6 +67,9 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
|
|||
[BlockEnum.DocExtractor]: DocExtractorNode,
|
||||
[BlockEnum.ListFilter]: ListFilterNode,
|
||||
[BlockEnum.Agent]: AgentNode,
|
||||
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
|
||||
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
|
||||
}
|
||||
|
||||
export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
|
|
@ -84,6 +93,9 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
|||
[BlockEnum.DocExtractor]: DocExtractorPanel,
|
||||
[BlockEnum.ListFilter]: ListFilterPanel,
|
||||
[BlockEnum.Agent]: AgentPanel,
|
||||
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
|
||||
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
|
||||
}
|
||||
|
||||
export const CUSTOM_NODE_TYPE = 'custom'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||
|
||||
const nodeDefault: NodeDefault<PluginTriggerNodeType> = {
|
||||
defaultValue: {
|
||||
plugin_id: '',
|
||||
plugin_name: '',
|
||||
event_type: '',
|
||||
config: {},
|
||||
},
|
||||
getAvailablePrevNodes(isChatMode: boolean) {
|
||||
return []
|
||||
},
|
||||
getAvailableNextNodes(isChatMode: boolean) {
|
||||
const nodes = isChatMode
|
||||
? ALL_CHAT_AVAILABLE_BLOCKS
|
||||
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
|
||||
return nodes.filter(type => type !== BlockEnum.Start)
|
||||
},
|
||||
checkValid(payload: PluginTriggerNodeType, t: any) {
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerPlugin'
|
||||
|
||||
const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mb-1 px-3 py-1">
|
||||
<div className="text-xs text-gray-700">
|
||||
{t(`${i18nPrefix}.nodeTitle`)}
|
||||
</div>
|
||||
{data.plugin_name && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{data.plugin_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerPlugin'
|
||||
|
||||
const Panel: FC<NodePanelProps<PluginTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-2'>
|
||||
<Field title={t(`${i18nPrefix}.title`)}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t(`${i18nPrefix}.configPlaceholder`)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
|
||||
export type PluginTriggerNodeType = CommonNodeType & {
|
||||
plugin_id?: string
|
||||
plugin_name?: string
|
||||
event_type?: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DateTimePicker from './date-time-picker'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'workflow.nodes.triggerSchedule.selectDateTime': 'Select Date & Time',
|
||||
'common.operation.now': 'Now',
|
||||
'common.operation.ok': 'OK',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DateTimePicker', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders with default value', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.textContent).toMatch(/\d+, \d{4} \d{1,2}:\d{2} [AP]M/)
|
||||
})
|
||||
|
||||
test('renders with provided value', () => {
|
||||
const testDate = new Date('2024-01-15T14:30:00.000Z')
|
||||
render(<DateTimePicker value={testDate.toISOString()} onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('opens picker when button is clicked', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Select Date & Time')).toBeInTheDocument()
|
||||
expect(screen.getByText('Now')).toBeInTheDocument()
|
||||
expect(screen.getByText('OK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('closes picker when clicking outside', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Select Date & Time')).toBeInTheDocument()
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(screen.queryByText('Select Date & Time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not call onChange when input changes without clicking OK', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('calls onChange when clicking OK button', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
|
||||
|
||||
const okButton = screen.getByText('OK')
|
||||
fireEvent.click(okButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/2024-12-25T.*:30.*Z/))
|
||||
})
|
||||
|
||||
test('calls onChange when clicking Now button', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/))
|
||||
})
|
||||
|
||||
test('resets temp value when reopening picker', async () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
const originalValue = input.getAttribute('value')
|
||||
|
||||
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
|
||||
expect(input.getAttribute('value')).toBe('2024-12-25T15:30')
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const newInput = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
expect(newInput.getAttribute('value')).toBe(originalValue)
|
||||
})
|
||||
})
|
||||
|
||||
test('displays current value in button text', () => {
|
||||
const testDate = new Date('2024-01-15T14:30:00.000Z')
|
||||
render(<DateTimePicker value={testDate.toISOString()} onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toMatch(/January 15, 2024/)
|
||||
expect(button.textContent).toMatch(/\d{1,2}:30 [AP]M/)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCalendarLine } from '@remixicon/react'
|
||||
import { getDefaultDateTime } from '../utils/execution-time-calculator'
|
||||
|
||||
type DateTimePickerProps = {
|
||||
value?: string
|
||||
onChange: (datetime: string) => void
|
||||
}
|
||||
|
||||
const DateTimePicker = ({ value, onChange }: DateTimePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [tempValue, setTempValue] = useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen)
|
||||
setTempValue('')
|
||||
}, [isOpen])
|
||||
|
||||
const getCurrentDateTime = () => {
|
||||
if (value) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
return `${date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})} ${date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}`
|
||||
}
|
||||
catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDate = getDefaultDateTime()
|
||||
|
||||
return `${defaultDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})} ${defaultDate.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}`
|
||||
}
|
||||
|
||||
const handleDateTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const dateTimeValue = event.target.value
|
||||
setTempValue(dateTimeValue)
|
||||
}
|
||||
|
||||
const getInputValue = () => {
|
||||
if (tempValue)
|
||||
return tempValue
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDate = getDefaultDateTime()
|
||||
const year = defaultDate.getFullYear()
|
||||
const month = String(defaultDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(defaultDate.getDate()).padStart(2, '0')
|
||||
const hours = String(defaultDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(defaultDate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex h-9 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-1.5 text-sm text-text-secondary hover:bg-components-input-bg-hover"
|
||||
>
|
||||
<span>{getCurrentDateTime()}</span>
|
||||
<RiCalendarLine className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-72 select-none rounded-xl border border-gray-200 bg-white p-4 shadow-lg">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">{t('workflow.nodes.triggerSchedule.selectDateTime')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-b border-gray-100" />
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={getInputValue()}
|
||||
onChange={handleDateTimeChange}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const now = new Date()
|
||||
onChange(now.toISOString())
|
||||
setTempValue('')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t('common.operation.now')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (tempValue) {
|
||||
const date = new Date(tempValue)
|
||||
onChange(date.toISOString())
|
||||
}
|
||||
setTempValue('')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="flex-1 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
setTempValue('')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateTimePicker
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ExecuteNowButtonProps = {
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ExecuteNowButton = ({ onClick, disabled = false }: ExecuteNowButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-1.5 text-xs font-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('workflow.nodes.triggerSchedule.executeNow')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExecuteNowButton
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import type { ScheduleFrequency } from '../types'
|
||||
|
||||
type FrequencySelectorProps = {
|
||||
frequency: ScheduleFrequency
|
||||
onChange: (frequency: ScheduleFrequency) => void
|
||||
}
|
||||
|
||||
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const frequencies = useMemo(() => [
|
||||
{ value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true },
|
||||
{ value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') },
|
||||
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
|
||||
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
|
||||
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
|
||||
{ value: 'once', name: t('workflow.nodes.triggerSchedule.frequency.once') },
|
||||
], [t])
|
||||
|
||||
return (
|
||||
<SimpleSelect
|
||||
key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render
|
||||
items={frequencies}
|
||||
defaultValue={frequency}
|
||||
onSelect={item => onChange(item.value as ScheduleFrequency)}
|
||||
placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')}
|
||||
className="w-full"
|
||||
optionWrapClassName="min-w-40"
|
||||
notClearable={true}
|
||||
allowSearch={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrequencySelector
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCalendarLine, RiCodeLine } from '@remixicon/react'
|
||||
import { SegmentedControl } from '@/app/components/base/segmented-control'
|
||||
import type { ScheduleMode } from '../types'
|
||||
|
||||
type ModeSwitcherProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: RiCalendarLine,
|
||||
text: t('workflow.nodes.triggerSchedule.mode.visual'),
|
||||
value: 'visual' as const,
|
||||
},
|
||||
{
|
||||
Icon: RiCodeLine,
|
||||
text: t('workflow.nodes.triggerSchedule.mode.cron'),
|
||||
value: 'cron' as const,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value={mode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeSwitcher
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAsterisk, RiCalendarLine } from '@remixicon/react'
|
||||
import type { ScheduleMode } from '../types'
|
||||
|
||||
type ModeToggleProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeToggle = ({ mode, onChange }: ModeToggleProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = () => {
|
||||
const newMode = mode === 'visual' ? 'cron' : 'visual'
|
||||
onChange(newMode)
|
||||
}
|
||||
|
||||
const currentText = mode === 'visual'
|
||||
? t('workflow.nodes.triggerSchedule.useCronExpression')
|
||||
: t('workflow.nodes.triggerSchedule.useVisualPicker')
|
||||
|
||||
const currentIcon = mode === 'visual' ? RiAsterisk : RiCalendarLine
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{React.createElement(currentIcon, { className: 'w-3 h-3' })}
|
||||
<span>{currentText}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeToggle
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type MonthlyDaysSelectorProps = {
|
||||
selectedDay: number | 'last'
|
||||
onChange: (day: number | 'last') => void
|
||||
}
|
||||
|
||||
const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const days = Array.from({ length: 31 }, (_, i) => i + 1)
|
||||
const rows = [
|
||||
days.slice(0, 7),
|
||||
days.slice(7, 14),
|
||||
days.slice(14, 21),
|
||||
days.slice(21, 28),
|
||||
[29, 30, 31, 'last' as const],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.days')}
|
||||
</label>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="grid grid-cols-7 gap-1.5">
|
||||
{row.map(day => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => onChange(day)}
|
||||
className={`rounded-lg py-1.5 text-xs transition-colors ${
|
||||
day === 'last' ? 'col-span-2 min-w-0' : ''
|
||||
} ${
|
||||
selectedDay === day
|
||||
? 'border-2 border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-components-input-border-normal border text-text-tertiary hover:border-components-input-border-hover hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{day === 'last' ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{t('workflow.nodes.triggerSchedule.lastDay')}</span>
|
||||
<Tooltip
|
||||
popupContent={t('workflow.nodes.triggerSchedule.lastDayTooltip')}
|
||||
>
|
||||
<RiQuestionLine className="h-3 w-3 text-text-quaternary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
day
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */}
|
||||
{rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => (
|
||||
<div key={`empty-${i}`} className="invisible"></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonthlyDaysSelector
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { getFormattedExecutionTimes } from '../utils/execution-time-calculator'
|
||||
|
||||
type NextExecutionTimesProps = {
|
||||
data: ScheduleTriggerNodeType
|
||||
}
|
||||
|
||||
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Don't show next execution times for 'once' frequency
|
||||
if (data.frequency === 'once')
|
||||
return null
|
||||
|
||||
const executionTimes = getFormattedExecutionTimes(data, 5)
|
||||
|
||||
if (executionTimes.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.nextExecutionTimes')}
|
||||
</label>
|
||||
<div className="space-y-2 rounded-lg bg-components-input-bg-normal p-3">
|
||||
{executionTimes.map((time, index) => (
|
||||
<div key={index} className="flex items-baseline gap-3 text-xs text-text-secondary">
|
||||
<span className="select-none font-mono leading-none text-text-quaternary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="leading-none">{time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NextExecutionTimes
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import { SimpleSegmentedControl } from './simple-segmented-control'
|
||||
|
||||
type RecurConfigProps = {
|
||||
recurEvery?: number
|
||||
recurUnit?: 'hours' | 'minutes'
|
||||
onRecurEveryChange: (value: number) => void
|
||||
onRecurUnitChange: (unit: 'hours' | 'minutes') => void
|
||||
}
|
||||
|
||||
const RecurConfig = ({
|
||||
recurEvery = 1,
|
||||
recurUnit = 'hours',
|
||||
onRecurEveryChange,
|
||||
onRecurUnitChange,
|
||||
}: RecurConfigProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const unitOptions = [
|
||||
{
|
||||
text: t('workflow.nodes.triggerSchedule.hours'),
|
||||
value: 'hours' as const,
|
||||
},
|
||||
{
|
||||
text: t('workflow.nodes.triggerSchedule.minutes'),
|
||||
value: 'minutes' as const,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-[2]">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
{t('workflow.nodes.triggerSchedule.recurEvery')}
|
||||
</label>
|
||||
<InputNumber
|
||||
value={recurEvery}
|
||||
onChange={value => onRecurEveryChange(value || 1)}
|
||||
min={1}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
|
||||
</label>
|
||||
<SimpleSegmentedControl
|
||||
options={unitOptions}
|
||||
value={recurUnit}
|
||||
onChange={onRecurUnitChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecurConfig
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
|
||||
// Simplified version without icons
|
||||
type SimpleSegmentedControlProps<T extends string | number | symbol> = {
|
||||
options: { text: string, value: T }[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SimpleSegmentedControl = <T extends string | number | symbol>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SimpleSegmentedControlProps<T>) => {
|
||||
const selectedOptionIndex = options.findIndex(option => option.value === value)
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex items-center gap-x-[1px] rounded-lg bg-components-segmented-control-bg-normal p-0.5',
|
||||
className,
|
||||
)}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedOptionIndex
|
||||
const isNextSelected = index === selectedOptionIndex - 1
|
||||
const isLast = index === options.length - 1
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
key={String(option.value)}
|
||||
className={classNames(
|
||||
'border-0.5 group relative flex flex-1 items-center justify-center gap-x-0.5 rounded-lg border-transparent px-2 py-1',
|
||||
isSelected
|
||||
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<span className={classNames(
|
||||
'system-sm-medium p-0.5 text-text-tertiary',
|
||||
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
|
||||
)}>
|
||||
{option.text}
|
||||
</span>
|
||||
{!isLast && !isSelected && !isNextSelected && (
|
||||
<div className='absolute right-[-1px] top-0 flex h-full items-center'>
|
||||
<Divider type='vertical' className='mx-0 h-3.5' />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SimpleSegmentedControl) as typeof SimpleSegmentedControl
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import TimePicker from './time-picker'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'time.title.pickTime': 'Pick Time',
|
||||
'common.operation.now': 'Now',
|
||||
'common.operation.ok': 'OK',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('TimePicker', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders with default value', () => {
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
})
|
||||
|
||||
test('renders with provided value', () => {
|
||||
render(<TimePicker value="2:30 PM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toBe('2:30 PM')
|
||||
})
|
||||
|
||||
test('opens picker when button is clicked', () => {
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Pick Time')).toBeInTheDocument()
|
||||
expect(screen.getByText('Now')).toBeInTheDocument()
|
||||
expect(screen.getByText('OK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('closes picker when clicking outside', () => {
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Pick Time')).toBeInTheDocument()
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(screen.queryByText('Pick Time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('button text remains unchanged when selecting time without clicking OK', () => {
|
||||
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
const hourButton = screen.getByText('3')
|
||||
fireEvent.click(hourButton)
|
||||
|
||||
const minuteButton = screen.getByText('45')
|
||||
fireEvent.click(minuteButton)
|
||||
|
||||
const pmButton = screen.getByText('PM')
|
||||
fireEvent.click(pmButton)
|
||||
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
})
|
||||
|
||||
test('calls onChange when clicking OK button', () => {
|
||||
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const hourButton = screen.getByText('3')
|
||||
fireEvent.click(hourButton)
|
||||
|
||||
const minuteButton = screen.getByText('45')
|
||||
fireEvent.click(minuteButton)
|
||||
|
||||
const pmButton = screen.getByText('PM')
|
||||
fireEvent.click(pmButton)
|
||||
|
||||
const okButton = screen.getByText('OK')
|
||||
fireEvent.click(okButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('3:45 PM')
|
||||
})
|
||||
|
||||
test('calls onChange when clicking Now button', () => {
|
||||
const mockDate = new Date('2024-01-15T14:30:00')
|
||||
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate)
|
||||
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('2:30 PM')
|
||||
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('initializes picker with current value when opened', async () => {
|
||||
render(<TimePicker value="3:45 PM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const selectedHour = screen.getByText('3').closest('button')
|
||||
expect(selectedHour).toHaveClass('bg-gray-100')
|
||||
|
||||
const selectedMinute = screen.getByText('45').closest('button')
|
||||
expect(selectedMinute).toHaveClass('bg-gray-100')
|
||||
|
||||
const selectedPeriod = screen.getByText('PM').closest('button')
|
||||
expect(selectedPeriod).toHaveClass('bg-gray-100')
|
||||
})
|
||||
})
|
||||
|
||||
test('resets picker selection when reopening after closing without OK', async () => {
|
||||
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const hourButton = screen.getByText('3')
|
||||
fireEvent.click(hourButton)
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const hourButtons = screen.getAllByText('11')
|
||||
const selectedHourButton = hourButtons.find(btn => btn.closest('button')?.classList.contains('bg-gray-100'))
|
||||
expect(selectedHourButton).toBeTruthy()
|
||||
|
||||
const notSelectedHour = screen.getByText('3').closest('button')
|
||||
expect(notSelectedHour).not.toHaveClass('bg-gray-100')
|
||||
})
|
||||
})
|
||||
|
||||
test('handles 12 AM/PM correctly in Now button', () => {
|
||||
const mockMidnight = new Date('2024-01-15T00:30:00')
|
||||
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockMidnight)
|
||||
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:30 AM')
|
||||
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('handles 12 PM correctly in Now button', () => {
|
||||
const mockNoon = new Date('2024-01-15T12:30:00')
|
||||
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockNoon)
|
||||
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:30 PM')
|
||||
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('auto-scrolls to selected values when opened', async () => {
|
||||
const mockScrollIntoView = jest.fn()
|
||||
Element.prototype.scrollIntoView = mockScrollIntoView
|
||||
|
||||
render(<TimePicker value="8:45 PM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}, { timeout: 200 })
|
||||
|
||||
mockScrollIntoView.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import React, { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiTimeLine } from '@remixicon/react'
|
||||
|
||||
const scrollbarHideStyles = {
|
||||
scrollbarWidth: 'none' as const,
|
||||
msOverflowStyle: 'none' as const,
|
||||
} as React.CSSProperties
|
||||
|
||||
type TimePickerProps = {
|
||||
value?: string
|
||||
onChange: (time: string) => void
|
||||
}
|
||||
|
||||
const TimePicker = ({ value = '11:30 AM', onChange }: TimePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedHour, setSelectedHour] = useState(11)
|
||||
const [selectedMinute, setSelectedMinute] = useState(30)
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'AM' | 'PM'>('AM')
|
||||
const hourContainerRef = useRef<HTMLDivElement>(null)
|
||||
const minuteContainerRef = useRef<HTMLDivElement>(null)
|
||||
const periodContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (value) {
|
||||
const match = value.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/)
|
||||
if (match) {
|
||||
setSelectedHour(Number.parseInt(match[1], 10))
|
||||
setSelectedMinute(Number.parseInt(match[2], 10))
|
||||
setSelectedPeriod(match[3] as 'AM' | 'PM')
|
||||
}
|
||||
}
|
||||
else {
|
||||
setSelectedHour(11)
|
||||
setSelectedMinute(30)
|
||||
setSelectedPeriod('AM')
|
||||
}
|
||||
}
|
||||
}, [isOpen, value])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
if (hourContainerRef.current) {
|
||||
const selectedHourElement = hourContainerRef.current.querySelector('.bg-state-base-active')
|
||||
if (selectedHourElement)
|
||||
selectedHourElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
if (minuteContainerRef.current) {
|
||||
const selectedMinuteElement = minuteContainerRef.current.querySelector('.bg-state-base-active')
|
||||
if (selectedMinuteElement)
|
||||
selectedMinuteElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
if (periodContainerRef.current) {
|
||||
const selectedPeriodElement = periodContainerRef.current.querySelector('.bg-state-base-active')
|
||||
if (selectedPeriodElement)
|
||||
selectedPeriodElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}, [isOpen, selectedHour, selectedMinute, selectedPeriod])
|
||||
|
||||
const hours = Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => i)
|
||||
const periods = ['AM', 'PM'] as const
|
||||
|
||||
// Create padding elements to ensure bottom options can scroll to top
|
||||
// Container shows 8 options (h-64), so we need 7 padding elements at bottom
|
||||
const createBottomPadding = () => Array.from({ length: 7 }, (_, i) => (
|
||||
<div key={`bottom-padding-${i}`} className="pointer-events-none h-8" />
|
||||
))
|
||||
|
||||
const handleNow = () => {
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
const minute = now.getMinutes()
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
let displayHour = hour
|
||||
if (hour === 0)
|
||||
displayHour = 12
|
||||
else if (hour > 12)
|
||||
displayHour = hour - 12
|
||||
|
||||
const timeString = `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
onChange(timeString)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleOK = () => {
|
||||
const timeString = `${selectedHour}:${selectedMinute.toString().padStart(2, '0')} ${selectedPeriod}`
|
||||
onChange(timeString)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex h-9 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-1.5 text-sm text-text-secondary hover:bg-components-input-bg-hover"
|
||||
>
|
||||
<span>{value}</span>
|
||||
<RiTimeLine className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-72 select-none rounded-xl border border-components-panel-border bg-components-panel-bg p-4 shadow-lg">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('time.title.pickTime')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-b border-components-panel-border-subtle" />
|
||||
|
||||
<div className="mb-4 flex gap-3">
|
||||
{/* Hours */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
ref={hourContainerRef}
|
||||
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={scrollbarHideStyles}
|
||||
data-testid="hour-selector"
|
||||
>
|
||||
{hours.map(hour => (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
|
||||
selectedHour === hour
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-state-base-hover'
|
||||
}`}
|
||||
onClick={() => setSelectedHour(hour)}
|
||||
>
|
||||
{hour}
|
||||
</button>
|
||||
))}
|
||||
{createBottomPadding()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minutes */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
ref={minuteContainerRef}
|
||||
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={scrollbarHideStyles}
|
||||
data-testid="minute-selector"
|
||||
>
|
||||
{minutes.map(minute => (
|
||||
<button
|
||||
key={minute}
|
||||
type="button"
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
|
||||
selectedMinute === minute
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-state-base-hover'
|
||||
}`}
|
||||
onClick={() => setSelectedMinute(minute)}
|
||||
>
|
||||
{minute.toString().padStart(2, '0')}
|
||||
</button>
|
||||
))}
|
||||
{createBottomPadding()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AM/PM */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
ref={periodContainerRef}
|
||||
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={scrollbarHideStyles}
|
||||
>
|
||||
{periods.map(period => (
|
||||
<button
|
||||
key={period}
|
||||
type="button"
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
|
||||
selectedPeriod === period
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-state-base-hover'
|
||||
}`}
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
>
|
||||
{period}
|
||||
</button>
|
||||
))}
|
||||
{createBottomPadding()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-b border-components-panel-border-subtle" />
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNow}
|
||||
className="flex-1 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-1 text-sm font-medium text-text-accent hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
{t('common.operation.now')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOK}
|
||||
className="flex-1 rounded-lg bg-components-button-primary-bg px-3 py-1 text-sm font-medium text-white hover:bg-components-button-primary-bg-hover"
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePicker
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type WeekdaySelectorProps = {
|
||||
selectedDays: string[]
|
||||
onChange: (days: string[]) => void
|
||||
}
|
||||
|
||||
const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const weekdays = [
|
||||
{ key: 'sun', label: 'Sun' },
|
||||
{ key: 'mon', label: 'Mon' },
|
||||
{ key: 'tue', label: 'Tue' },
|
||||
{ key: 'wed', label: 'Wed' },
|
||||
{ key: 'thu', label: 'Thu' },
|
||||
{ key: 'fri', label: 'Fri' },
|
||||
{ key: 'sat', label: 'Sat' },
|
||||
]
|
||||
|
||||
const selectedDay = selectedDays.length > 0 ? selectedDays[0] : 'sun'
|
||||
|
||||
const handleDaySelect = (dayKey: string) => {
|
||||
onChange([dayKey])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.weekdays')}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
{weekdays.map(day => (
|
||||
<button
|
||||
key={day.key}
|
||||
type="button"
|
||||
className={`flex-1 rounded-lg py-1.5 text-xs transition-colors ${
|
||||
selectedDay === day.key
|
||||
? 'border-2 border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-components-input-border-normal border text-text-tertiary hover:border-components-input-border-hover hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() => handleDaySelect(day.key)}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeekdaySelector
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||
|
||||
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
||||
defaultValue: {
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
cron_expression: '',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
enabled: true,
|
||||
},
|
||||
getAvailablePrevNodes(_isChatMode: boolean) {
|
||||
return []
|
||||
},
|
||||
getAvailableNextNodes(isChatMode: boolean) {
|
||||
const nodes = isChatMode
|
||||
? ALL_CHAT_AVAILABLE_BLOCKS
|
||||
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
|
||||
return nodes.filter(type => type !== BlockEnum.Start)
|
||||
},
|
||||
checkValid(_payload: ScheduleTriggerNodeType, _t: any) {
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { getNextExecutionTime } from './utils/execution-time-calculator'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerSchedule'
|
||||
|
||||
const Node: FC<NodeProps<ScheduleTriggerNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mb-1 px-3 py-1">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-text-tertiary">
|
||||
{t(`${i18nPrefix}.nextExecutionTime`)}
|
||||
</div>
|
||||
<div className="flex h-[26px] items-center rounded-md bg-workflow-block-parma-bg px-2 text-xs text-text-secondary">
|
||||
{getNextExecutionTime(data)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import ModeToggle from './components/mode-toggle'
|
||||
import FrequencySelector from './components/frequency-selector'
|
||||
import WeekdaySelector from './components/weekday-selector'
|
||||
import TimePicker from './components/time-picker'
|
||||
import DateTimePicker from './components/date-time-picker'
|
||||
import NextExecutionTimes from './components/next-execution-times'
|
||||
import ExecuteNowButton from './components/execute-now-button'
|
||||
import RecurConfig from './components/recur-config'
|
||||
import MonthlyDaysSelector from './components/monthly-days-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import useConfig from './use-config'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerSchedule'
|
||||
|
||||
const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
inputs,
|
||||
setInputs,
|
||||
handleModeChange,
|
||||
handleFrequencyChange,
|
||||
handleCronExpressionChange,
|
||||
handleWeekdaysChange,
|
||||
handleTimeChange,
|
||||
handleRecurEveryChange,
|
||||
handleRecurUnitChange,
|
||||
} = useConfig(id, data)
|
||||
|
||||
const handleExecuteNow = () => {
|
||||
// TODO: Implement execute now functionality
|
||||
console.log('Execute now clicked')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-3 pt-2'>
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.title`)}
|
||||
operations={
|
||||
<ModeToggle
|
||||
mode={inputs.mode}
|
||||
onChange={handleModeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
|
||||
{inputs.mode === 'visual' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.frequencyLabel')}
|
||||
</label>
|
||||
<FrequencySelector
|
||||
frequency={inputs.frequency}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{inputs.frequency === 'hourly' || inputs.frequency === 'once'
|
||||
? t('workflow.nodes.triggerSchedule.startTime')
|
||||
: t('workflow.nodes.triggerSchedule.time')
|
||||
}
|
||||
</label>
|
||||
{inputs.frequency === 'hourly' || inputs.frequency === 'once' ? (
|
||||
<DateTimePicker
|
||||
value={inputs.visual_config?.datetime}
|
||||
onChange={(datetime) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
datetime,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TimePicker
|
||||
value={inputs.visual_config?.time || '11:30 AM'}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputs.frequency === 'weekly' && (
|
||||
<WeekdaySelector
|
||||
selectedDays={inputs.visual_config?.weekdays || []}
|
||||
onChange={handleWeekdaysChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.frequency === 'hourly' && (
|
||||
<RecurConfig
|
||||
recurEvery={inputs.visual_config?.recur_every}
|
||||
recurUnit={inputs.visual_config?.recur_unit}
|
||||
onRecurEveryChange={handleRecurEveryChange}
|
||||
onRecurUnitChange={handleRecurUnitChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.frequency === 'monthly' && (
|
||||
<MonthlyDaysSelector
|
||||
selectedDay={inputs.visual_config?.monthly_day || 1}
|
||||
onChange={(day) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
monthly_day: day,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.mode === 'cron' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.cronExpression')}
|
||||
</label>
|
||||
<Input
|
||||
value={inputs.cron_expression || ''}
|
||||
onChange={e => handleCronExpressionChange(e.target.value)}
|
||||
placeholder="0 0 * * *"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Enter cron expression (minute hour day month weekday)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="border-t border-divider-subtle"></div>
|
||||
|
||||
<NextExecutionTimes data={inputs} />
|
||||
|
||||
<div className="pt-2">
|
||||
<ExecuteNowButton onClick={handleExecuteNow} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { useCallback } from 'react'
|
||||
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
|
||||
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
|
||||
const defaultPayload = {
|
||||
...payload,
|
||||
mode: payload.mode || 'visual',
|
||||
frequency: payload.frequency || 'daily',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
...payload.visual_config,
|
||||
},
|
||||
timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
enabled: payload.enabled !== undefined ? payload.enabled : true,
|
||||
}
|
||||
|
||||
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, defaultPayload)
|
||||
|
||||
const handleModeChange = useCallback((mode: ScheduleMode) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
mode,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
frequency,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleCronExpressionChange = useCallback((value: string) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
cron_expression: value,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleWeekdaysChange = useCallback((weekdays: string[]) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
weekdays,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleTimeChange = useCallback((time: string) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
time,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleRecurEveryChange = useCallback((recur_every: number) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
recur_every,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleRecurUnitChange = useCallback((recur_unit: 'hours' | 'minutes') => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
recur_unit,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
setInputs,
|
||||
handleModeChange,
|
||||
handleFrequencyChange,
|
||||
handleCronExpressionChange,
|
||||
handleWeekdaysChange,
|
||||
handleTimeChange,
|
||||
handleRecurEveryChange,
|
||||
handleRecurUnitChange,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,795 @@
|
|||
import { formatExecutionTime, getDefaultDateTime, getFormattedExecutionTimes, getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
|
||||
const createMockData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||
id: 'test-node',
|
||||
type: 'schedule-trigger',
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
recur_every: 1,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Use system timezone for consistent tests
|
||||
enabled: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('execution-time-calculator', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0)) // Local time: 2024-01-15 10:00:00
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
describe('formatExecutionTime', () => {
|
||||
test('formats time with weekday by default', () => {
|
||||
const date = new Date(2024, 0, 16, 14, 30)
|
||||
const result = formatExecutionTime(date)
|
||||
|
||||
expect(result).toBe('Tue, January 16, 2024 2:30 PM')
|
||||
})
|
||||
|
||||
test('formats time without weekday when specified', () => {
|
||||
const date = new Date(2024, 0, 16, 14, 30)
|
||||
const result = formatExecutionTime(date, false)
|
||||
|
||||
expect(result).toBe('January 16, 2024 2:30 PM')
|
||||
})
|
||||
|
||||
test('handles morning times correctly', () => {
|
||||
const date = new Date(2024, 0, 16, 9, 15)
|
||||
const result = formatExecutionTime(date)
|
||||
|
||||
expect(result).toBe('Tue, January 16, 2024 9:15 AM')
|
||||
})
|
||||
|
||||
test('handles midnight correctly', () => {
|
||||
const date = new Date(2024, 0, 16, 0, 0)
|
||||
const result = formatExecutionTime(date)
|
||||
|
||||
expect(result).toBe('Tue, January 16, 2024 12:00 AM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - daily frequency', () => {
|
||||
test('calculates next 5 daily executions', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
expect(result[0].getHours()).toBe(14)
|
||||
expect(result[0].getMinutes()).toBe(30)
|
||||
expect(result[1].getDate()).toBe(result[0].getDate() + 1)
|
||||
})
|
||||
|
||||
test('handles past time by moving to next day', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // 3:00 PM local time
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getDate()).toBe(16)
|
||||
})
|
||||
|
||||
test('handles AM/PM conversion correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '11:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getHours()).toBe(23)
|
||||
expect(result[0].getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
test('handles 12 AM correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '12:00 AM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getHours()).toBe(0)
|
||||
})
|
||||
|
||||
test('handles 12 PM correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '12:00 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getHours()).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - weekly frequency', () => {
|
||||
test('calculates next 5 weekly executions for Sunday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDay()).toBe(0)
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('calculates next execution for Monday from Monday', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 0))
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['mon'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result[0].getDate()).toBe(15)
|
||||
expect(result[1].getDate()).toBe(22)
|
||||
})
|
||||
|
||||
test('moves to next week when current day time has passed', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // Monday 3:00 PM local time
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['mon'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getDate()).toBe(22)
|
||||
})
|
||||
|
||||
test('handles different weekdays correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '9:00 AM',
|
||||
weekdays: ['fri'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getDay()).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - hourly frequency', () => {
|
||||
test('calculates hourly intervals correctly', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 2,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getTime() - startTime.getTime()).toBe(2 * 60 * 60 * 1000)
|
||||
expect(result[1].getTime() - startTime.getTime()).toBe(4 * 60 * 60 * 1000)
|
||||
expect(result[2].getTime() - startTime.getTime()).toBe(6 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
test('calculates minute intervals correctly', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 30,
|
||||
recur_unit: 'minutes',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getTime() - startTime.getTime()).toBe(30 * 60 * 1000)
|
||||
expect(result[1].getTime() - startTime.getTime()).toBe(60 * 60 * 1000)
|
||||
})
|
||||
|
||||
test('handles past start time by calculating next interval', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 14, 30, 0)) // Local time 2:30 PM
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 1,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result[0].getHours()).toBe(15)
|
||||
expect(result[1].getHours()).toBe(16)
|
||||
})
|
||||
|
||||
test('uses current time as default start time', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
recur_every: 1,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getTime()).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
test('minute intervals should not have duplicates when recur_every changes', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0)
|
||||
|
||||
// Test with recur_every = 2 minutes
|
||||
const data2 = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 2,
|
||||
recur_unit: 'minutes',
|
||||
},
|
||||
})
|
||||
|
||||
const result2 = getNextExecutionTimes(data2, 5)
|
||||
|
||||
// Check for no duplicates in result2
|
||||
const timestamps2 = result2.map(date => date.getTime())
|
||||
const uniqueTimestamps2 = new Set(timestamps2)
|
||||
expect(timestamps2.length).toBe(uniqueTimestamps2.size)
|
||||
|
||||
// Check intervals are correct for 2-minute intervals
|
||||
for (let i = 1; i < result2.length; i++) {
|
||||
const timeDiff = result2[i].getTime() - result2[i - 1].getTime()
|
||||
expect(timeDiff).toBe(2 * 60 * 1000) // 2 minutes in milliseconds
|
||||
}
|
||||
})
|
||||
|
||||
test('hourly intervals should handle recur_every changes correctly', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0)
|
||||
|
||||
// Test with recur_every = 3 hours
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 3,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 4)
|
||||
|
||||
// Check for no duplicates
|
||||
const timestamps = result.map(date => date.getTime())
|
||||
const uniqueTimestamps = new Set(timestamps)
|
||||
expect(timestamps.length).toBe(uniqueTimestamps.size)
|
||||
|
||||
// Check intervals are correct for 3-hour intervals
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const timeDiff = result[i].getTime() - result[i - 1].getTime()
|
||||
expect(timeDiff).toBe(3 * 60 * 60 * 1000) // 3 hours in milliseconds
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - cron mode', () => {
|
||||
test('uses cron parser for cron expressions', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: '0 12 * * *',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(12)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('returns empty array for invalid cron expression', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty array for missing cron expression', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: '',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - once frequency', () => {
|
||||
test('returns selected datetime for once frequency', () => {
|
||||
const selectedTime = new Date(2024, 0, 20, 15, 30, 0) // January 20, 2024 3:30 PM
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {
|
||||
datetime: selectedTime.toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].getTime()).toBe(selectedTime.getTime())
|
||||
})
|
||||
|
||||
test('returns empty array when no datetime selected for once frequency', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - fallback behavior', () => {
|
||||
test('handles unknown frequency by returning next days', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'unknown' as any,
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getDate()).toBe(16)
|
||||
expect(result[1].getDate()).toBe(17)
|
||||
expect(result[2].getDate()).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFormattedExecutionTimes', () => {
|
||||
test('formats daily execution times without weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
expect(result[0]).toMatch(/January \d+, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('formats weekly execution times with weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatch(/^Sun, January \d+, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('formats hourly execution times without weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: new Date(2024, 0, 16, 14, 0, 0).toISOString(), // Local time 2:00 PM
|
||||
recur_every: 2,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0]).toMatch(/January 16, 2024 4:00 PM/)
|
||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
})
|
||||
|
||||
test('returns empty array when no execution times', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid',
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTime', () => {
|
||||
test('returns first formatted execution time', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January \d+, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('returns current time when no execution times available for non-once frequencies', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January 15, 2024 10:00 AM/)
|
||||
})
|
||||
|
||||
test('returns default datetime for once frequency when no datetime configured', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January 16, 2024 11:30 AM/)
|
||||
})
|
||||
|
||||
test('returns configured datetime for once frequency when available', () => {
|
||||
const selectedTime = new Date(2024, 0, 20, 15, 30, 0)
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {
|
||||
datetime: selectedTime.toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January 20, 2024 3:30 PM/)
|
||||
})
|
||||
|
||||
test('applies correct weekday formatting based on frequency', () => {
|
||||
const weeklyData = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
})
|
||||
|
||||
const dailyData = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const weeklyResult = getNextExecutionTime(weeklyData)
|
||||
const dailyResult = getNextExecutionTime(dailyData)
|
||||
|
||||
expect(weeklyResult).toMatch(/^Sun,/)
|
||||
expect(dailyResult).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
test('handles missing visual_config gracefully', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: undefined,
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('uses default values for missing config properties', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('handles malformed time strings gracefully', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: 'invalid time' },
|
||||
})
|
||||
|
||||
expect(() => getNextExecutionTimes(data, 1)).not.toThrow()
|
||||
})
|
||||
|
||||
test('returns reasonable defaults for zero count', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 0)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('daily frequency should not have duplicate dates', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
|
||||
// Check that each date is unique and consecutive
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const prevDate = result[i - 1].getDate()
|
||||
const currDate = result[i].getDate()
|
||||
expect(currDate).not.toBe(prevDate) // No duplicates
|
||||
expect(currDate - prevDate).toBe(1) // Should be consecutive days
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - monthly frequency', () => {
|
||||
test('returns monthly execution times for specific day', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDate()).toBe(15)
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
expect(result[0].getMonth()).toBe(0) // January
|
||||
expect(result[1].getMonth()).toBe(1) // February
|
||||
expect(result[2].getMonth()).toBe(2) // March
|
||||
})
|
||||
|
||||
test('returns monthly execution times for last day', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
monthly_day: 'last',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 4)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(11)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
expect(result[0].getDate()).toBe(31) // January 31
|
||||
expect(result[1].getDate()).toBe(29) // February 29 (2024 is leap year)
|
||||
expect(result[2].getDate()).toBe(31) // March 31
|
||||
expect(result[3].getDate()).toBe(30) // April 30
|
||||
})
|
||||
|
||||
test('handles day 31 in months with fewer days', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '3:00 PM',
|
||||
monthly_day: 31,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 4)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].getDate()).toBe(31) // January 31
|
||||
expect(result[1].getDate()).toBe(29) // February 29 (can't have 31)
|
||||
expect(result[2].getDate()).toBe(31) // March 31
|
||||
expect(result[3].getDate()).toBe(30) // April 30 (can't have 31)
|
||||
})
|
||||
|
||||
test('handles day 30 in February', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '9:00 AM',
|
||||
monthly_day: 30,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getDate()).toBe(30) // January 30
|
||||
expect(result[1].getDate()).toBe(29) // February 29 (max in 2024)
|
||||
expect(result[2].getDate()).toBe(30) // March 30
|
||||
})
|
||||
|
||||
test('skips to next month if current month execution has passed', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2024, 0, 20, 15, 0, 0)) // January 20, 2024 3:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15, // Already passed in January
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getMonth()).toBe(1) // February (skip January)
|
||||
expect(result[1].getMonth()).toBe(2) // March
|
||||
expect(result[2].getMonth()).toBe(3) // April
|
||||
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('includes current month if execution time has not passed', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2024, 0, 10, 10, 0, 0)) // January 10, 2024 10:00 AM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15, // Still upcoming in January
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getMonth()).toBe(0) // January (current month)
|
||||
expect(result[1].getMonth()).toBe(1) // February
|
||||
expect(result[2].getMonth()).toBe(2) // March
|
||||
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('handles AM/PM time conversion correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '11:30 PM',
|
||||
monthly_day: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(23) // 11 PM in 24-hour format
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
expect(date.getDate()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('formats monthly execution times without weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 1)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
expect(result[0]).toMatch(/January 15, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('uses default day 1 when monthly_day is not specified', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '10:00 AM',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDate()).toBe(1)
|
||||
expect(date.getHours()).toBe(10)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultDateTime', () => {
|
||||
test('returns consistent default datetime', () => {
|
||||
const defaultDate = getDefaultDateTime()
|
||||
|
||||
expect(defaultDate.getHours()).toBe(11)
|
||||
expect(defaultDate.getMinutes()).toBe(30)
|
||||
expect(defaultDate.getSeconds()).toBe(0)
|
||||
expect(defaultDate.getMilliseconds()).toBe(0)
|
||||
expect(defaultDate.getDate()).toBe(new Date().getDate() + 1)
|
||||
})
|
||||
|
||||
test('default datetime matches DateTimePicker fallback behavior', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const nextExecutionTime = getNextExecutionTime(data)
|
||||
const defaultDate = getDefaultDateTime()
|
||||
const expectedFormat = formatExecutionTime(defaultDate, false)
|
||||
|
||||
expect(nextExecutionTime).toBe(expectedFormat)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { WebhookTriggerNodeType } from './types'
|
||||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||
|
||||
const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
|
||||
defaultValue: {
|
||||
webhook_url: '',
|
||||
http_methods: ['POST'],
|
||||
authorization: {
|
||||
type: 'none',
|
||||
},
|
||||
},
|
||||
getAvailablePrevNodes(isChatMode: boolean) {
|
||||
return []
|
||||
},
|
||||
getAvailableNextNodes(isChatMode: boolean) {
|
||||
const nodes = isChatMode
|
||||
? ALL_CHAT_AVAILABLE_BLOCKS
|
||||
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
|
||||
return nodes.filter(type => type !== BlockEnum.Start)
|
||||
},
|
||||
checkValid(payload: WebhookTriggerNodeType, t: any) {
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { WebhookTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerWebhook'
|
||||
|
||||
const Node: FC<NodeProps<WebhookTriggerNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mb-1 px-3 py-1">
|
||||
<div className="text-xs text-gray-700">
|
||||
{t(`${i18nPrefix}.nodeTitle`)}
|
||||
</div>
|
||||
{data.http_methods && data.http_methods.length > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{data.http_methods.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { WebhookTriggerNodeType } from './types'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.triggerWebhook'
|
||||
|
||||
const Panel: FC<NodePanelProps<WebhookTriggerNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-2'>
|
||||
<Field title={t(`${i18nPrefix}.title`)}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t(`${i18nPrefix}.configPlaceholder`)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
|
||||
export type WebhookTriggerNodeType = CommonNodeType & {
|
||||
webhook_url?: string
|
||||
http_methods?: string[]
|
||||
authorization?: {
|
||||
type: 'none' | 'bearer' | 'api_key'
|
||||
config?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}}',
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const translation = {
|
|||
more: 'もっと',
|
||||
selectAll: 'すべて選択',
|
||||
deSelectAll: 'すべて選択解除',
|
||||
now: '今',
|
||||
},
|
||||
errorMsg: {
|
||||
fieldRequired: '{{field}}は必要です',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const translation = {
|
|||
more: '更多',
|
||||
selectAll: '全选',
|
||||
deSelectAll: '取消全选',
|
||||
now: '现在',
|
||||
},
|
||||
errorMsg: {
|
||||
fieldRequired: '{{field}} 为必填项',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue