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

Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
This commit is contained in:
lyzno1
2025-08-18 09:23:16 +08:00
committed by GitHub
parent f214eeb7b1
commit 74ad21b145
52 changed files with 3709 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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