dify
This commit is contained in:
@@ -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') },
|
||||
], [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 py-2"
|
||||
wrapperClassName="h-auto"
|
||||
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 { Asterisk, CalendarCheckLine } from '@/app/components/base/icons/src/vender/workflow'
|
||||
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' ? Asterisk : CalendarCheckLine
|
||||
|
||||
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-4 h-4' })}
|
||||
<span>{currentText}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeToggle
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type MonthlyDaysSelectorProps = {
|
||||
selectedDays: (number | 'last')[]
|
||||
onChange: (days: (number | 'last')[]) => void
|
||||
}
|
||||
|
||||
const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleDayClick = (day: number | 'last') => {
|
||||
const current = selectedDays || []
|
||||
const newSelected = current.includes(day)
|
||||
? current.filter(d => d !== day)
|
||||
: [...current, day]
|
||||
// Ensure at least one day is selected (consistent with WeekdaySelector)
|
||||
onChange(newSelected.length > 0 ? newSelected : [day])
|
||||
}
|
||||
|
||||
const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false
|
||||
|
||||
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-text-tertiary">
|
||||
{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={() => handleDayClick(day)}
|
||||
className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${
|
||||
day === 'last' ? 'col-span-2 min-w-0' : ''
|
||||
} ${
|
||||
isDaySelected(day)
|
||||
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular 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>
|
||||
|
||||
{/* Warning message for day 31 - aligned with grid */}
|
||||
{selectedDays?.includes(31) && (
|
||||
<div className="mt-1.5 grid grid-cols-7 gap-1.5">
|
||||
<div className="col-span-7 text-xs text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.lastDayTooltip')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonthlyDaysSelector
|
||||
@@ -0,0 +1,42 @@
|
||||
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()
|
||||
|
||||
if (!data.frequency)
|
||||
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="flex min-h-[80px] flex-col rounded-xl bg-components-input-bg-normal py-2">
|
||||
{executionTimes.map((time, index) => (
|
||||
<div key={index} className="flex items-baseline text-xs">
|
||||
<span className="w-6 select-none text-right font-mono font-normal leading-[150%] tracking-wider text-text-quaternary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="pl-2 pr-3 font-mono font-normal leading-[150%] tracking-wider text-text-secondary">
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NextExecutionTimes
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
|
||||
type OnMinuteSelectorProps = {
|
||||
value?: number
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.onMinute')}
|
||||
</label>
|
||||
<div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal">
|
||||
<div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled">
|
||||
{value}
|
||||
</div>
|
||||
<div className="absolute left-12 top-0 h-full w-px bg-components-panel-bg"></div>
|
||||
<div className="flex h-full grow items-center pl-4 pr-3">
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={value}
|
||||
min={0}
|
||||
max={59}
|
||||
step={1}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnMinuteSelector
|
||||
@@ -0,0 +1,57 @@
|
||||
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 handleDaySelect = (dayKey: string) => {
|
||||
const current = selectedDays || []
|
||||
const newSelected = current.includes(dayKey)
|
||||
? current.filter(d => d !== dayKey)
|
||||
: [...current, dayKey]
|
||||
onChange(newSelected.length > 0 ? newSelected : [dayKey])
|
||||
}
|
||||
|
||||
const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
{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 border bg-components-option-card-option-bg py-1 text-xs transition-colors ${
|
||||
isDaySelected(day.key)
|
||||
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() => handleDaySelect(day.key)}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeekdaySelector
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
|
||||
export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
visual_config: {
|
||||
time: '12:00 AM',
|
||||
weekdays: ['sun'],
|
||||
on_minute: 0,
|
||||
monthly_days: [1],
|
||||
},
|
||||
})
|
||||
|
||||
export const getDefaultVisualConfig = () => ({
|
||||
time: '12:00 AM',
|
||||
weekdays: ['sun'],
|
||||
on_minute: 0,
|
||||
monthly_days: [1],
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import { isValidCronExpression } from './utils/cron-parser'
|
||||
import { getNextExecutionTimes } from './utils/execution-time-calculator'
|
||||
import { getDefaultScheduleConfig } from './constants'
|
||||
import { genNodeMetaData } from '../../utils'
|
||||
|
||||
const isValidTimeFormat = (time: string): boolean => {
|
||||
const timeRegex = /^(0?\d|1[0-2]):[0-5]\d (AM|PM)$/
|
||||
if (!timeRegex.test(time)) return false
|
||||
|
||||
const [timePart, period] = time.split(' ')
|
||||
const [hour, minute] = timePart.split(':')
|
||||
const hourNum = Number.parseInt(hour, 10)
|
||||
const minuteNum = Number.parseInt(minute, 10)
|
||||
|
||||
return hourNum >= 1 && hourNum <= 12
|
||||
&& minuteNum >= 0 && minuteNum <= 59
|
||||
&& ['AM', 'PM'].includes(period)
|
||||
}
|
||||
|
||||
const validateHourlyConfig = (config: any, t: any): string => {
|
||||
if (config.on_minute === undefined || config.on_minute < 0 || config.on_minute > 59)
|
||||
return t('workflow.nodes.triggerSchedule.invalidOnMinute')
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateDailyConfig = (config: any, t: any): string => {
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
if (!config.time)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.time') })
|
||||
|
||||
if (!isValidTimeFormat(config.time))
|
||||
return t('workflow.nodes.triggerSchedule.invalidTimeFormat')
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateWeeklyConfig = (config: any, t: any): string => {
|
||||
const dailyError = validateDailyConfig(config, t)
|
||||
if (dailyError) return dailyError
|
||||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
if (!config.weekdays || config.weekdays.length === 0)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.weekdays') })
|
||||
|
||||
const validWeekdays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
||||
for (const day of config.weekdays) {
|
||||
if (!validWeekdays.includes(day))
|
||||
return t('workflow.nodes.triggerSchedule.invalidWeekday', { weekday: day })
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateMonthlyConfig = (config: any, t: any): string => {
|
||||
const dailyError = validateDailyConfig(config, t)
|
||||
if (dailyError) return dailyError
|
||||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
const getMonthlyDays = (): (number | 'last')[] => {
|
||||
if (Array.isArray(config.monthly_days) && config.monthly_days.length > 0)
|
||||
return config.monthly_days
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const monthlyDays = getMonthlyDays()
|
||||
|
||||
if (monthlyDays.length === 0)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') })
|
||||
|
||||
for (const day of monthlyDays) {
|
||||
if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31))
|
||||
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => {
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
const { visual_config } = payload
|
||||
|
||||
if (!visual_config)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.visualConfig') })
|
||||
|
||||
switch (payload.frequency) {
|
||||
case 'hourly':
|
||||
return validateHourlyConfig(visual_config, t)
|
||||
case 'daily':
|
||||
return validateDailyConfig(visual_config, t)
|
||||
case 'weekly':
|
||||
return validateWeeklyConfig(visual_config, t)
|
||||
case 'monthly':
|
||||
return validateMonthlyConfig(visual_config, t)
|
||||
default:
|
||||
return t('workflow.nodes.triggerSchedule.invalidFrequency')
|
||||
}
|
||||
}
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 2,
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
helpLinkUri: 'schedule-trigger',
|
||||
isStart: true,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
...getDefaultScheduleConfig(),
|
||||
cron_expression: '',
|
||||
} as ScheduleTriggerNodeType,
|
||||
checkValid(payload: ScheduleTriggerNodeType, t: any) {
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
let errorMessages = ''
|
||||
if (!errorMessages && !payload.mode)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.mode') })
|
||||
|
||||
// Validate timezone format if provided (timezone will be auto-filled by use-config.ts if undefined)
|
||||
if (!errorMessages && payload.timezone) {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: payload.timezone })
|
||||
}
|
||||
catch {
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.invalidTimezone')
|
||||
}
|
||||
}
|
||||
if (!errorMessages) {
|
||||
if (payload.mode === 'cron') {
|
||||
if (!payload.cron_expression || payload.cron_expression.trim() === '')
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.cronExpression') })
|
||||
else if (!isValidCronExpression(payload.cron_expression))
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.invalidCronExpression')
|
||||
}
|
||||
else if (payload.mode === 'visual') {
|
||||
if (!payload.frequency)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.frequency') })
|
||||
else
|
||||
errorMessages = validateVisualConfig(payload, t)
|
||||
}
|
||||
}
|
||||
if (!errorMessages) {
|
||||
try {
|
||||
const nextTimes = getNextExecutionTimes(payload, 1)
|
||||
if (nextTimes.length === 0)
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.noValidExecutionTime')
|
||||
}
|
||||
catch {
|
||||
errorMessages = t('workflow.nodes.triggerSchedule.executionTimeCalculationError')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: !errorMessages,
|
||||
errorMessage: errorMessages,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
@@ -0,0 +1,31 @@
|
||||
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">
|
||||
<div className="w-0 grow">
|
||||
<div className="truncate" title={getNextExecutionTime(data)}>
|
||||
{getNextExecutionTime(data)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
@@ -0,0 +1,146 @@
|
||||
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 '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import NextExecutionTimes from './components/next-execution-times'
|
||||
import MonthlyDaysSelector from './components/monthly-days-selector'
|
||||
import OnMinuteSelector from './components/on-minute-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,
|
||||
handleOnMinuteChange,
|
||||
} = useConfig(id, data)
|
||||
|
||||
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 || 'daily'}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
{inputs.frequency === 'hourly' ? (
|
||||
<OnMinuteSelector
|
||||
value={inputs.visual_config?.on_minute}
|
||||
onChange={handleOnMinuteChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.time')}
|
||||
</label>
|
||||
<TimePicker
|
||||
notClearable={true}
|
||||
timezone={inputs.timezone}
|
||||
value={inputs.visual_config?.time || '12:00 AM'}
|
||||
triggerFullWidth={true}
|
||||
onChange={(time) => {
|
||||
if (time) {
|
||||
const timeString = time.format('h:mm A')
|
||||
handleTimeChange(timeString)
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
handleTimeChange('12:00 AM')
|
||||
}}
|
||||
placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
|
||||
showTimezone={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputs.frequency === 'weekly' && (
|
||||
<WeekdaySelector
|
||||
selectedDays={inputs.visual_config?.weekdays || []}
|
||||
onChange={handleWeekdaysChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.frequency === 'monthly' && (
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={inputs.visual_config?.monthly_days || [1]}
|
||||
onChange={(days) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
monthly_days: days,
|
||||
},
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="border-t border-divider-subtle"></div>
|
||||
|
||||
<NextExecutionTimes data={inputs} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
|
||||
export type ScheduleMode = 'visual' | 'cron'
|
||||
|
||||
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export type VisualConfig = {
|
||||
time?: string
|
||||
weekdays?: string[]
|
||||
on_minute?: number
|
||||
monthly_days?: (number | 'last')[]
|
||||
}
|
||||
|
||||
export type ScheduleTriggerNodeType = CommonNodeType & {
|
||||
mode: ScheduleMode
|
||||
frequency?: ScheduleFrequency
|
||||
cron_expression?: string
|
||||
visual_config?: VisualConfig
|
||||
timezone?: string
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useMemo } 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'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { getDefaultVisualConfig } from './constants'
|
||||
|
||||
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
|
||||
const { userProfile } = useAppContext()
|
||||
|
||||
const frontendPayload = useMemo(() => {
|
||||
return {
|
||||
...payload,
|
||||
mode: payload.mode || 'visual',
|
||||
frequency: payload.frequency || 'daily',
|
||||
timezone: payload.timezone || userProfile.timezone || 'UTC',
|
||||
visual_config: {
|
||||
...getDefaultVisualConfig(),
|
||||
...payload.visual_config,
|
||||
},
|
||||
}
|
||||
}, [payload, userProfile.timezone])
|
||||
|
||||
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload)
|
||||
|
||||
const handleModeChange = useCallback((mode: ScheduleMode) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
mode,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleFrequencyChange = useCallback((frequency: ScheduleFrequency) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
frequency,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
...(frequency === 'hourly') && {
|
||||
on_minute: inputs.visual_config?.on_minute ?? 0,
|
||||
},
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleCronExpressionChange = useCallback((value: string) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
cron_expression: value,
|
||||
frequency: undefined,
|
||||
visual_config: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleWeekdaysChange = useCallback((weekdays: string[]) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
weekdays,
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleTimeChange = useCallback((time: string) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
time,
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
const handleOnMinuteChange = useCallback((on_minute: number) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
on_minute,
|
||||
},
|
||||
cron_expression: undefined,
|
||||
}
|
||||
setInputs(newInputs)
|
||||
}, [inputs, setInputs])
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
inputs,
|
||||
setInputs,
|
||||
handleModeChange,
|
||||
handleFrequencyChange,
|
||||
handleCronExpressionChange,
|
||||
handleWeekdaysChange,
|
||||
handleTimeChange,
|
||||
handleOnMinuteChange,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
|
||||
// Convert a UTC date from cron-parser to user timezone representation
|
||||
// This ensures consistency with other execution time calculations
|
||||
const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => {
|
||||
// Get the time string in the target timezone
|
||||
const userTimeStr = utcDate.toLocaleString('en-CA', {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
})
|
||||
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
const [hour, minute, second] = timeStr.split(':').map(Number)
|
||||
|
||||
// Create a new Date object representing this time as "local" time
|
||||
// This matches the behavior expected by the execution-time-calculator
|
||||
return new Date(year, month - 1, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron expression and return the next 5 execution times
|
||||
*
|
||||
* @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek)
|
||||
* @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York')
|
||||
* @returns Array of Date objects representing the next 5 execution times
|
||||
*/
|
||||
export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return []
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
|
||||
// Support both 5-field format and predefined expressions
|
||||
if (parts.length !== 5 && !cronExpression.startsWith('@'))
|
||||
return []
|
||||
|
||||
try {
|
||||
// Parse the cron expression with timezone support
|
||||
// Use the actual current time for cron-parser to handle properly
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
tz: timezone,
|
||||
})
|
||||
|
||||
// Get the next 5 execution times using the take() method
|
||||
const nextCronDates = interval.take(5)
|
||||
|
||||
// Convert CronDate objects to Date objects and ensure they represent
|
||||
// the time in user timezone (consistent with execution-time-calculator.ts)
|
||||
return nextCronDates.map((cronDate) => {
|
||||
const utcDate = cronDate.toDate()
|
||||
return convertToUserTimezoneRepresentation(utcDate, timezone)
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Return empty array if parsing fails
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression format and syntax
|
||||
*
|
||||
* @param cronExpression - Standard 5-field cron expression to validate
|
||||
* @returns boolean indicating if the cron expression is valid
|
||||
*/
|
||||
export const isValidCronExpression = (cronExpression: string): boolean => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return false
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
|
||||
// Support both 5-field format and predefined expressions
|
||||
if (parts.length !== 5 && !cronExpression.startsWith('@'))
|
||||
return false
|
||||
|
||||
try {
|
||||
// Use cron-parser to validate the expression
|
||||
CronExpressionParser.parse(cronExpression)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
|
||||
const DEFAULT_TIMEZONE = 'UTC'
|
||||
|
||||
const resolveTimezone = (timezone?: string): string => {
|
||||
if (timezone)
|
||||
return timezone
|
||||
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIMEZONE
|
||||
}
|
||||
catch {
|
||||
return DEFAULT_TIMEZONE
|
||||
}
|
||||
}
|
||||
|
||||
// Get current time completely in user timezone, no browser timezone involved
|
||||
const getUserTimezoneCurrentTime = (timezone?: string): Date => {
|
||||
const targetTimezone = resolveTimezone(timezone)
|
||||
const now = new Date()
|
||||
const userTimeStr = now.toLocaleString('en-CA', {
|
||||
timeZone: targetTimezone,
|
||||
hour12: false,
|
||||
})
|
||||
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
const [hour, minute, second] = timeStr.split(':').map(Number)
|
||||
return new Date(year, month - 1, day, hour, minute, second)
|
||||
}
|
||||
|
||||
// Format date that is already in user timezone, no timezone conversion
|
||||
const formatUserTimezoneDate = (date: Date, timezone: string, includeWeekday: boolean = true, includeTimezone: boolean = true): string => {
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
|
||||
if (includeWeekday)
|
||||
dateOptions.weekday = 'long' // Changed from 'short' to 'long' for full weekday name
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}
|
||||
|
||||
const dateStr = date.toLocaleDateString('en-US', dateOptions)
|
||||
const timeStr = date.toLocaleTimeString('en-US', timeOptions)
|
||||
|
||||
if (includeTimezone) {
|
||||
const timezoneOffset = convertTimezoneToOffsetStr(timezone)
|
||||
return `${dateStr}, ${timeStr} (${timezoneOffset})`
|
||||
}
|
||||
|
||||
return `${dateStr}, ${timeStr}`
|
||||
}
|
||||
|
||||
// Helper function to get default datetime - consistent with base DatePicker
|
||||
export const getDefaultDateTime = (): Date => {
|
||||
const defaultDate = new Date(2024, 0, 2, 11, 30, 0, 0)
|
||||
return defaultDate
|
||||
}
|
||||
|
||||
export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => {
|
||||
const timezone = resolveTimezone(data.timezone)
|
||||
|
||||
if (data.mode === 'cron') {
|
||||
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
||||
return []
|
||||
return parseCronExpression(data.cron_expression, timezone).slice(0, count)
|
||||
}
|
||||
|
||||
const times: Date[] = []
|
||||
const defaultTime = data.visual_config?.time || '12:00 AM'
|
||||
|
||||
// Get "today" in user's timezone for display purposes
|
||||
const now = new Date()
|
||||
const userTodayStr = now.toLocaleDateString('en-CA', { timeZone: timezone })
|
||||
const [year, month, day] = userTodayStr.split('-').map(Number)
|
||||
const userToday = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||
|
||||
if (data.frequency === 'hourly') {
|
||||
const onMinute = data.visual_config?.on_minute ?? 0
|
||||
|
||||
// Get current time completely in user timezone
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
let hour = userCurrentTime.getHours()
|
||||
if (userCurrentTime.getMinutes() >= onMinute)
|
||||
hour += 1 // Start from next hour if current minute has passed
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const execution = new Date(userToday)
|
||||
execution.setHours(hour + i, onMinute, 0, 0)
|
||||
// Handle day overflow
|
||||
if (hour + i >= 24) {
|
||||
execution.setDate(userToday.getDate() + Math.floor((hour + i) / 24))
|
||||
execution.setHours((hour + i) % 24, onMinute, 0, 0)
|
||||
}
|
||||
times.push(execution)
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
// Check if today's configured time has already passed
|
||||
const todayExecution = new Date(userToday)
|
||||
todayExecution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
const startOffset = todayExecution <= userCurrentTime ? 1 : 0
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const execution = new Date(userToday)
|
||||
execution.setDate(userToday.getDate() + startOffset + i)
|
||||
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
times.push(execution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'weekly') {
|
||||
const selectedDays = data.visual_config?.weekdays || ['sun']
|
||||
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
|
||||
|
||||
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
|
||||
|
||||
// Get current time completely in user timezone
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
let executionCount = 0
|
||||
let weekOffset = 0
|
||||
|
||||
while (executionCount < count) {
|
||||
let hasValidDays = false
|
||||
|
||||
for (const selectedDay of selectedDays) {
|
||||
if (executionCount >= count) break
|
||||
|
||||
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
||||
if (targetDay === undefined) continue
|
||||
|
||||
hasValidDays = true
|
||||
|
||||
const currentDayOfWeek = userToday.getDay()
|
||||
const daysUntilTarget = (targetDay - currentDayOfWeek + 7) % 7
|
||||
|
||||
// Check if today's configured time has already passed
|
||||
const todayAtTargetTime = new Date(userToday)
|
||||
todayAtTargetTime.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
let adjustedDays = daysUntilTarget
|
||||
if (daysUntilTarget === 0 && todayAtTargetTime <= userCurrentTime)
|
||||
adjustedDays = 7
|
||||
|
||||
const execution = new Date(userToday)
|
||||
execution.setDate(userToday.getDate() + adjustedDays + (weekOffset * 7))
|
||||
execution.setHours(displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
// Only add if execution time is in the future
|
||||
if (execution > userCurrentTime) {
|
||||
times.push(execution)
|
||||
executionCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidDays) break
|
||||
weekOffset++
|
||||
}
|
||||
|
||||
times.sort((a, b) => a.getTime() - b.getTime())
|
||||
}
|
||||
else if (data.frequency === 'monthly') {
|
||||
const getSelectedDays = (): (number | 'last')[] => {
|
||||
if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0)
|
||||
return data.visual_config.monthly_days
|
||||
|
||||
return [1]
|
||||
}
|
||||
|
||||
const selectedDays = [...new Set(getSelectedDays())]
|
||||
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
|
||||
|
||||
// Get current time completely in user timezone
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
|
||||
let executionCount = 0
|
||||
let monthOffset = 0
|
||||
|
||||
while (executionCount < count) {
|
||||
const targetMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
|
||||
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||
|
||||
const monthlyExecutions: Date[] = []
|
||||
const processedDays = new Set<number>()
|
||||
|
||||
for (const selectedDay of selectedDays) {
|
||||
let targetDay: number
|
||||
|
||||
if (selectedDay === 'last') {
|
||||
targetDay = daysInMonth
|
||||
}
|
||||
else {
|
||||
const dayNumber = selectedDay as number
|
||||
if (dayNumber > daysInMonth)
|
||||
continue
|
||||
|
||||
targetDay = dayNumber
|
||||
}
|
||||
|
||||
if (processedDays.has(targetDay))
|
||||
continue
|
||||
|
||||
processedDays.add(targetDay)
|
||||
|
||||
const execution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
// Only add if execution time is in the future
|
||||
if (execution > userCurrentTime)
|
||||
monthlyExecutions.push(execution)
|
||||
}
|
||||
|
||||
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
|
||||
|
||||
for (const execution of monthlyExecutions) {
|
||||
if (executionCount >= count) break
|
||||
times.push(execution)
|
||||
executionCount++
|
||||
}
|
||||
|
||||
monthOffset++
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const execution = new Date(userToday)
|
||||
execution.setDate(userToday.getDate() + i)
|
||||
times.push(execution)
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
export const formatExecutionTime = (date: Date, timezone: string | undefined, includeWeekday: boolean = true, includeTimezone: boolean = true): string => {
|
||||
const resolvedTimezone = resolveTimezone(timezone)
|
||||
return formatUserTimezoneDate(date, resolvedTimezone, includeWeekday, includeTimezone)
|
||||
}
|
||||
|
||||
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
|
||||
const timezone = resolveTimezone(data.timezone)
|
||||
const times = getNextExecutionTimes(data, count)
|
||||
|
||||
return times.map((date) => {
|
||||
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||
return formatExecutionTime(date, timezone, includeWeekday, true) // Panel shows timezone
|
||||
})
|
||||
}
|
||||
|
||||
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
||||
const timezone = resolveTimezone(data.timezone)
|
||||
|
||||
// Return placeholder for cron mode with empty or invalid expression
|
||||
if (data.mode === 'cron') {
|
||||
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
||||
return '--'
|
||||
}
|
||||
|
||||
// Get Date objects (not formatted strings)
|
||||
const times = getNextExecutionTimes(data, 1)
|
||||
if (times.length === 0) {
|
||||
const userCurrentTime = getUserTimezoneCurrentTime(timezone)
|
||||
const fallbackDate = new Date(userCurrentTime.getFullYear(), userCurrentTime.getMonth(), userCurrentTime.getDate(), 12, 0, 0, 0)
|
||||
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||
return formatExecutionTime(fallbackDate, timezone, includeWeekday, false) // Node doesn't show timezone
|
||||
}
|
||||
|
||||
// Format the first execution time without timezone for node display
|
||||
const includeWeekday = data.mode === 'visual' && data.frequency === 'weekly'
|
||||
return formatExecutionTime(times[0], timezone, includeWeekday, false) // Node doesn't show timezone
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { BlockEnum } from '../../../types'
|
||||
|
||||
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
|
||||
describe('cron-parser + execution-time-calculator integration', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
title: 'test-schedule',
|
||||
mode: 'cron',
|
||||
frequency: 'daily',
|
||||
timezone: 'UTC',
|
||||
...overrides,
|
||||
} as ScheduleTriggerNodeType)
|
||||
|
||||
describe('backward compatibility validation', () => {
|
||||
it('maintains exact behavior for legacy cron expressions', () => {
|
||||
const legacyExpressions = [
|
||||
'15 10 1 * *', // Monthly 1st at 10:15
|
||||
'0 0 * * 0', // Weekly Sunday midnight
|
||||
'*/5 * * * *', // Every 5 minutes
|
||||
'0 9-17 * * 1-5', // Business hours weekdays
|
||||
'30 14 * * 1', // Monday 14:30
|
||||
'0 0 1,15 * *', // 1st and 15th midnight
|
||||
]
|
||||
|
||||
legacyExpressions.forEach((expression) => {
|
||||
// Test direct cron-parser usage
|
||||
const directResult = parseCronExpression(expression, 'UTC')
|
||||
expect(directResult).toHaveLength(5)
|
||||
expect(isValidCronExpression(expression)).toBe(true)
|
||||
|
||||
// Test through execution-time-calculator
|
||||
const data = createCronData({ cron_expression: expression })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(calculatorResult).toHaveLength(5)
|
||||
|
||||
// Results should be identical
|
||||
directResult.forEach((directDate, index) => {
|
||||
const calcDate = calculatorResult[index]
|
||||
expect(calcDate.getTime()).toBe(directDate.getTime())
|
||||
expect(calcDate.getHours()).toBe(directDate.getHours())
|
||||
expect(calcDate.getMinutes()).toBe(directDate.getMinutes())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('validates timezone handling consistency', () => {
|
||||
const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London']
|
||||
const expression = '0 12 * * *' // Daily noon
|
||||
|
||||
timezones.forEach((timezone) => {
|
||||
// Direct cron-parser call
|
||||
const directResult = parseCronExpression(expression, timezone)
|
||||
|
||||
// Through execution-time-calculator
|
||||
const data = createCronData({ cron_expression: expression, timezone })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(directResult).toHaveLength(5)
|
||||
expect(calculatorResult).toHaveLength(5)
|
||||
|
||||
// All results should show noon (12:00) in their respective timezone
|
||||
directResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
calculatorResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
|
||||
// Cross-validation: results should be identical
|
||||
directResult.forEach((directDate, index) => {
|
||||
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('error handling consistency', () => {
|
||||
const invalidExpressions = [
|
||||
'', // Empty string
|
||||
' ', // Whitespace only
|
||||
'60 10 1 * *', // Invalid minute
|
||||
'15 25 1 * *', // Invalid hour
|
||||
'15 10 32 * *', // Invalid day
|
||||
'15 10 1 13 *', // Invalid month
|
||||
'15 10 1', // Too few fields
|
||||
'15 10 1 * * *', // Too many fields
|
||||
'invalid expression', // Completely invalid
|
||||
]
|
||||
|
||||
invalidExpressions.forEach((expression) => {
|
||||
// Direct cron-parser calls
|
||||
expect(isValidCronExpression(expression)).toBe(false)
|
||||
expect(parseCronExpression(expression, 'UTC')).toEqual([])
|
||||
|
||||
// Through execution-time-calculator
|
||||
const data = createCronData({ cron_expression: expression })
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
expect(result).toEqual([])
|
||||
|
||||
// getNextExecutionTime should return '--' for invalid cron
|
||||
const timeString = getNextExecutionTime(data)
|
||||
expect(timeString).toBe('--')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('enhanced features integration', () => {
|
||||
it('month and day abbreviations work end-to-end', () => {
|
||||
const enhancedExpressions = [
|
||||
{ expr: '0 9 1 JAN *', month: 0, day: 1, hour: 9 }, // January 1st 9 AM
|
||||
{ expr: '0 15 * * MON', weekday: 1, hour: 15 }, // Monday 3 PM
|
||||
{ expr: '30 10 15 JUN,DEC *', month: [5, 11], day: 15, hour: 10, minute: 30 }, // Jun/Dec 15th
|
||||
{ expr: '0 12 * JAN-MAR *', month: [0, 1, 2], hour: 12 }, // Q1 noon
|
||||
]
|
||||
|
||||
enhancedExpressions.forEach(({ expr, month, day, weekday, hour, minute = 0 }) => {
|
||||
// Validate through both paths
|
||||
expect(isValidCronExpression(expr)).toBe(true)
|
||||
|
||||
const directResult = parseCronExpression(expr, 'UTC')
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const calculatorResult = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Validate expected properties
|
||||
const validateDate = (date: Date) => {
|
||||
expect(date.getHours()).toBe(hour)
|
||||
expect(date.getMinutes()).toBe(minute)
|
||||
|
||||
if (month !== undefined) {
|
||||
if (Array.isArray(month))
|
||||
expect(month).toContain(date.getMonth())
|
||||
else
|
||||
expect(date.getMonth()).toBe(month)
|
||||
}
|
||||
|
||||
if (day !== undefined)
|
||||
expect(date.getDate()).toBe(day)
|
||||
|
||||
if (weekday !== undefined)
|
||||
expect(date.getDay()).toBe(weekday)
|
||||
}
|
||||
|
||||
directResult.forEach(validateDate)
|
||||
calculatorResult.forEach(validateDate)
|
||||
})
|
||||
})
|
||||
|
||||
it('predefined expressions work through execution-time-calculator', () => {
|
||||
const predefExpressions = [
|
||||
{ expr: '@daily', hour: 0, minute: 0 },
|
||||
{ expr: '@weekly', hour: 0, minute: 0, weekday: 0 }, // Sunday
|
||||
{ expr: '@monthly', hour: 0, minute: 0, day: 1 }, // 1st of month
|
||||
{ expr: '@yearly', hour: 0, minute: 0, month: 0, day: 1 }, // Jan 1st
|
||||
]
|
||||
|
||||
predefExpressions.forEach(({ expr, hour, minute, weekday, day, month }) => {
|
||||
expect(isValidCronExpression(expr)).toBe(true)
|
||||
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(hour)
|
||||
expect(date.getMinutes()).toBe(minute)
|
||||
|
||||
if (weekday !== undefined) expect(date.getDay()).toBe(weekday)
|
||||
if (day !== undefined) expect(date.getDate()).toBe(day)
|
||||
if (month !== undefined) expect(date.getMonth()).toBe(month)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('special characters integration', () => {
|
||||
const specialExpressions = [
|
||||
'0 9 ? * 1', // ? wildcard for day
|
||||
'0 12 * * 7', // Sunday as 7
|
||||
'0 15 L * *', // Last day of month
|
||||
]
|
||||
|
||||
specialExpressions.forEach((expr) => {
|
||||
// Should validate and parse successfully
|
||||
expect(isValidCronExpression(expr)).toBe(true)
|
||||
|
||||
const directResult = parseCronExpression(expr, 'UTC')
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const calculatorResult = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Results should be consistent
|
||||
expect(calculatorResult[0].getHours()).toBe(directResult[0].getHours())
|
||||
expect(calculatorResult[0].getMinutes()).toBe(directResult[0].getMinutes())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DST and timezone edge cases', () => {
|
||||
it('handles DST transitions consistently', () => {
|
||||
// Test around DST spring forward (March 2024)
|
||||
jest.setSystemTime(new Date('2024-03-08T10:00:00Z'))
|
||||
|
||||
const expression = '0 2 * * *' // 2 AM daily (problematic during DST)
|
||||
const timezone = 'America/New_York'
|
||||
|
||||
const directResult = parseCronExpression(expression, timezone)
|
||||
const data = createCronData({ cron_expression: expression, timezone })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Both should handle DST gracefully
|
||||
// During DST spring forward, 2 AM becomes 3 AM - this is correct behavior
|
||||
directResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
|
||||
calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
|
||||
|
||||
// Results should be identical
|
||||
directResult.forEach((directDate, index) => {
|
||||
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
it('complex timezone scenarios', () => {
|
||||
const scenarios = [
|
||||
{ tz: 'Asia/Kolkata', expr: '30 14 * * *', expectedHour: 14, expectedMinute: 30 }, // UTC+5:30
|
||||
{ tz: 'Australia/Adelaide', expr: '0 8 * * *', expectedHour: 8, expectedMinute: 0 }, // UTC+9:30/+10:30
|
||||
{ tz: 'Pacific/Kiritimati', expr: '0 12 * * *', expectedHour: 12, expectedMinute: 0 }, // UTC+14
|
||||
]
|
||||
|
||||
scenarios.forEach(({ tz, expr, expectedHour, expectedMinute }) => {
|
||||
const directResult = parseCronExpression(expr, tz)
|
||||
const data = createCronData({ cron_expression: expr, timezone: tz })
|
||||
const calculatorResult = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Validate expected time
|
||||
directResult.forEach((date) => {
|
||||
expect(date.getHours()).toBe(expectedHour)
|
||||
expect(date.getMinutes()).toBe(expectedMinute)
|
||||
})
|
||||
|
||||
calculatorResult.forEach((date) => {
|
||||
expect(date.getHours()).toBe(expectedHour)
|
||||
expect(date.getMinutes()).toBe(expectedMinute)
|
||||
})
|
||||
|
||||
// Cross-validate consistency
|
||||
expect(calculatorResult[0].getTime()).toBe(directResult[0].getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance and reliability', () => {
|
||||
it('handles high-frequency expressions efficiently', () => {
|
||||
const highFreqExpressions = [
|
||||
'*/1 * * * *', // Every minute
|
||||
'*/5 * * * *', // Every 5 minutes
|
||||
'0,15,30,45 * * * *', // Every 15 minutes
|
||||
]
|
||||
|
||||
highFreqExpressions.forEach((expr) => {
|
||||
const start = performance.now()
|
||||
|
||||
// Test both direct and through calculator
|
||||
const directResult = parseCronExpression(expr, 'UTC')
|
||||
const data = createCronData({ cron_expression: expr })
|
||||
const calculatorResult = getNextExecutionTimes(data, 5)
|
||||
|
||||
const end = performance.now()
|
||||
|
||||
expect(directResult).toHaveLength(5)
|
||||
expect(calculatorResult).toHaveLength(5)
|
||||
expect(end - start).toBeLessThan(100) // Should be fast
|
||||
|
||||
// Results should be consistent
|
||||
directResult.forEach((directDate, index) => {
|
||||
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('stress test with complex expressions', () => {
|
||||
const complexExpressions = [
|
||||
'15,45 8-18 1,15 JAN-MAR MON-FRI', // Business hours, specific days, Q1, weekdays
|
||||
'0 */2 ? * SUN#1,SUN#3', // First and third Sunday, every 2 hours
|
||||
'30 9 L * *', // Last day of month, 9:30 AM
|
||||
]
|
||||
|
||||
complexExpressions.forEach((expr) => {
|
||||
if (isValidCronExpression(expr)) {
|
||||
const directResult = parseCronExpression(expr, 'America/New_York')
|
||||
const data = createCronData({
|
||||
cron_expression: expr,
|
||||
timezone: 'America/New_York',
|
||||
})
|
||||
const calculatorResult = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(directResult.length).toBeGreaterThan(0)
|
||||
expect(calculatorResult.length).toBeGreaterThan(0)
|
||||
|
||||
// Validate consistency where results exist
|
||||
const minLength = Math.min(directResult.length, calculatorResult.length)
|
||||
for (let i = 0; i < minLength; i++)
|
||||
expect(calculatorResult[i].getTime()).toBe(directResult[i].getTime())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('format compatibility', () => {
|
||||
it('getNextExecutionTime formatting consistency', () => {
|
||||
const testCases = [
|
||||
{ expr: '0 9 * * *', timezone: 'UTC' },
|
||||
{ expr: '30 14 * * 1-5', timezone: 'America/New_York' },
|
||||
{ expr: '@daily', timezone: 'Asia/Tokyo' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ expr, timezone }) => {
|
||||
const data = createCronData({ cron_expression: expr, timezone })
|
||||
const timeString = getNextExecutionTime(data)
|
||||
|
||||
// Should return a formatted time string, not '--'
|
||||
expect(timeString).not.toBe('--')
|
||||
expect(typeof timeString).toBe('string')
|
||||
expect(timeString.length).toBeGreaterThan(0)
|
||||
|
||||
// Should contain expected format elements
|
||||
expect(timeString).toMatch(/\d+:\d+/) // Time format
|
||||
expect(timeString).toMatch(/AM|PM/) // 12-hour format
|
||||
expect(timeString).toMatch(/\d{4}/) // Year
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user