mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
feat: comprehensive trigger node system with Schedule Trigger implementation (#24039)
Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
This commit is contained in:
@ -0,0 +1,139 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DateTimePicker from './date-time-picker'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'workflow.nodes.triggerSchedule.selectDateTime': 'Select Date & Time',
|
||||
'common.operation.now': 'Now',
|
||||
'common.operation.ok': 'OK',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DateTimePicker', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders with default value', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.textContent).toMatch(/\d+, \d{4} \d{1,2}:\d{2} [AP]M/)
|
||||
})
|
||||
|
||||
test('renders with provided value', () => {
|
||||
const testDate = new Date('2024-01-15T14:30:00.000Z')
|
||||
render(<DateTimePicker value={testDate.toISOString()} onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('opens picker when button is clicked', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Select Date & Time')).toBeInTheDocument()
|
||||
expect(screen.getByText('Now')).toBeInTheDocument()
|
||||
expect(screen.getByText('OK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('closes picker when clicking outside', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Select Date & Time')).toBeInTheDocument()
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(screen.queryByText('Select Date & Time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not call onChange when input changes without clicking OK', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('calls onChange when clicking OK button', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
|
||||
|
||||
const okButton = screen.getByText('OK')
|
||||
fireEvent.click(okButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/2024-12-25T.*:30.*Z/))
|
||||
})
|
||||
|
||||
test('calls onChange when clicking Now button', () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/))
|
||||
})
|
||||
|
||||
test('resets temp value when reopening picker', async () => {
|
||||
render(<DateTimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const input = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
const originalValue = input.getAttribute('value')
|
||||
|
||||
fireEvent.change(input, { target: { value: '2024-12-25T15:30' } })
|
||||
expect(input.getAttribute('value')).toBe('2024-12-25T15:30')
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const newInput = screen.getByDisplayValue(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)
|
||||
expect(newInput.getAttribute('value')).toBe(originalValue)
|
||||
})
|
||||
})
|
||||
|
||||
test('displays current value in button text', () => {
|
||||
const testDate = new Date('2024-01-15T14:30:00.000Z')
|
||||
render(<DateTimePicker value={testDate.toISOString()} onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toMatch(/January 15, 2024/)
|
||||
expect(button.textContent).toMatch(/\d{1,2}:30 [AP]M/)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,158 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCalendarLine } from '@remixicon/react'
|
||||
import { getDefaultDateTime } from '../utils/execution-time-calculator'
|
||||
|
||||
type DateTimePickerProps = {
|
||||
value?: string
|
||||
onChange: (datetime: string) => void
|
||||
}
|
||||
|
||||
const DateTimePicker = ({ value, onChange }: DateTimePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [tempValue, setTempValue] = useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen)
|
||||
setTempValue('')
|
||||
}, [isOpen])
|
||||
|
||||
const getCurrentDateTime = () => {
|
||||
if (value) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
return `${date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})} ${date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}`
|
||||
}
|
||||
catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDate = getDefaultDateTime()
|
||||
|
||||
return `${defaultDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})} ${defaultDate.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}`
|
||||
}
|
||||
|
||||
const handleDateTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const dateTimeValue = event.target.value
|
||||
setTempValue(dateTimeValue)
|
||||
}
|
||||
|
||||
const getInputValue = () => {
|
||||
if (tempValue)
|
||||
return tempValue
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDate = getDefaultDateTime()
|
||||
const year = defaultDate.getFullYear()
|
||||
const month = String(defaultDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(defaultDate.getDate()).padStart(2, '0')
|
||||
const hours = String(defaultDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(defaultDate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex h-9 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-1.5 text-sm text-text-secondary hover:bg-components-input-bg-hover"
|
||||
>
|
||||
<span>{getCurrentDateTime()}</span>
|
||||
<RiCalendarLine className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-72 select-none rounded-xl border border-gray-200 bg-white p-4 shadow-lg">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">{t('workflow.nodes.triggerSchedule.selectDateTime')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-b border-gray-100" />
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={getInputValue()}
|
||||
onChange={handleDateTimeChange}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const now = new Date()
|
||||
onChange(now.toISOString())
|
||||
setTempValue('')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t('common.operation.now')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (tempValue) {
|
||||
const date = new Date(tempValue)
|
||||
onChange(date.toISOString())
|
||||
}
|
||||
setTempValue('')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="flex-1 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
setTempValue('')
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateTimePicker
|
||||
@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ExecuteNowButtonProps = {
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ExecuteNowButton = ({ onClick, disabled = false }: ExecuteNowButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-1.5 text-xs font-medium text-components-button-secondary-text shadow-xs hover:bg-components-button-secondary-bg-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('workflow.nodes.triggerSchedule.executeNow')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExecuteNowButton
|
||||
@ -0,0 +1,38 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import type { ScheduleFrequency } from '../types'
|
||||
|
||||
type FrequencySelectorProps = {
|
||||
frequency: ScheduleFrequency
|
||||
onChange: (frequency: ScheduleFrequency) => void
|
||||
}
|
||||
|
||||
const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const frequencies = useMemo(() => [
|
||||
{ value: 'frequency-header', name: t('workflow.nodes.triggerSchedule.frequency.label'), isGroup: true },
|
||||
{ value: 'hourly', name: t('workflow.nodes.triggerSchedule.frequency.hourly') },
|
||||
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
|
||||
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
|
||||
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
|
||||
{ value: 'once', name: t('workflow.nodes.triggerSchedule.frequency.once') },
|
||||
], [t])
|
||||
|
||||
return (
|
||||
<SimpleSelect
|
||||
key={`${frequency}-${frequencies[0]?.name}`} // Include translation in key to force re-render
|
||||
items={frequencies}
|
||||
defaultValue={frequency}
|
||||
onSelect={item => onChange(item.value as ScheduleFrequency)}
|
||||
placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')}
|
||||
className="w-full"
|
||||
optionWrapClassName="min-w-40"
|
||||
notClearable={true}
|
||||
allowSearch={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrequencySelector
|
||||
@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCalendarLine, RiCodeLine } from '@remixicon/react'
|
||||
import { SegmentedControl } from '@/app/components/base/segmented-control'
|
||||
import type { ScheduleMode } from '../types'
|
||||
|
||||
type ModeSwitcherProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: RiCalendarLine,
|
||||
text: t('workflow.nodes.triggerSchedule.mode.visual'),
|
||||
value: 'visual' as const,
|
||||
},
|
||||
{
|
||||
Icon: RiCodeLine,
|
||||
text: t('workflow.nodes.triggerSchedule.mode.cron'),
|
||||
value: 'cron' as const,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
value={mode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeSwitcher
|
||||
@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAsterisk, RiCalendarLine } from '@remixicon/react'
|
||||
import type { ScheduleMode } from '../types'
|
||||
|
||||
type ModeToggleProps = {
|
||||
mode: ScheduleMode
|
||||
onChange: (mode: ScheduleMode) => void
|
||||
}
|
||||
|
||||
const ModeToggle = ({ mode, onChange }: ModeToggleProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleToggle = () => {
|
||||
const newMode = mode === 'visual' ? 'cron' : 'visual'
|
||||
onChange(newMode)
|
||||
}
|
||||
|
||||
const currentText = mode === 'visual'
|
||||
? t('workflow.nodes.triggerSchedule.useCronExpression')
|
||||
: t('workflow.nodes.triggerSchedule.useVisualPicker')
|
||||
|
||||
const currentIcon = mode === 'visual' ? RiAsterisk : RiCalendarLine
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{React.createElement(currentIcon, { className: 'w-3 h-3' })}
|
||||
<span>{currentText}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeToggle
|
||||
@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type MonthlyDaysSelectorProps = {
|
||||
selectedDay: number | 'last'
|
||||
onChange: (day: number | 'last') => void
|
||||
}
|
||||
|
||||
const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const days = Array.from({ length: 31 }, (_, i) => i + 1)
|
||||
const rows = [
|
||||
days.slice(0, 7),
|
||||
days.slice(7, 14),
|
||||
days.slice(14, 21),
|
||||
days.slice(21, 28),
|
||||
[29, 30, 31, 'last' as const],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.days')}
|
||||
</label>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="grid grid-cols-7 gap-1.5">
|
||||
{row.map(day => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => onChange(day)}
|
||||
className={`rounded-lg py-1.5 text-xs transition-colors ${
|
||||
day === 'last' ? 'col-span-2 min-w-0' : ''
|
||||
} ${
|
||||
selectedDay === day
|
||||
? 'border-2 border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-components-input-border-normal border text-text-tertiary hover:border-components-input-border-hover hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{day === 'last' ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{t('workflow.nodes.triggerSchedule.lastDay')}</span>
|
||||
<Tooltip
|
||||
popupContent={t('workflow.nodes.triggerSchedule.lastDayTooltip')}
|
||||
>
|
||||
<RiQuestionLine className="h-3 w-3 text-text-quaternary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
day
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Fill empty cells in the last row (Last day takes 2 cols, so need 1 less) */}
|
||||
{rowIndex === rows.length - 1 && Array.from({ length: 7 - row.length - 1 }, (_, i) => (
|
||||
<div key={`empty-${i}`} className="invisible"></div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonthlyDaysSelector
|
||||
@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { getFormattedExecutionTimes } from '../utils/execution-time-calculator'
|
||||
|
||||
type NextExecutionTimesProps = {
|
||||
data: ScheduleTriggerNodeType
|
||||
}
|
||||
|
||||
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Don't show next execution times for 'once' frequency
|
||||
if (data.frequency === 'once')
|
||||
return null
|
||||
|
||||
const executionTimes = getFormattedExecutionTimes(data, 5)
|
||||
|
||||
if (executionTimes.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.nextExecutionTimes')}
|
||||
</label>
|
||||
<div className="space-y-2 rounded-lg bg-components-input-bg-normal p-3">
|
||||
{executionTimes.map((time, index) => (
|
||||
<div key={index} className="flex items-baseline gap-3 text-xs text-text-secondary">
|
||||
<span className="select-none font-mono leading-none text-text-quaternary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="leading-none">{time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NextExecutionTimes
|
||||
@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import { SimpleSegmentedControl } from './simple-segmented-control'
|
||||
|
||||
type RecurConfigProps = {
|
||||
recurEvery?: number
|
||||
recurUnit?: 'hours' | 'minutes'
|
||||
onRecurEveryChange: (value: number) => void
|
||||
onRecurUnitChange: (unit: 'hours' | 'minutes') => void
|
||||
}
|
||||
|
||||
const RecurConfig = ({
|
||||
recurEvery = 1,
|
||||
recurUnit = 'hours',
|
||||
onRecurEveryChange,
|
||||
onRecurUnitChange,
|
||||
}: RecurConfigProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const unitOptions = [
|
||||
{
|
||||
text: t('workflow.nodes.triggerSchedule.hours'),
|
||||
value: 'hours' as const,
|
||||
},
|
||||
{
|
||||
text: t('workflow.nodes.triggerSchedule.minutes'),
|
||||
value: 'minutes' as const,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-[2]">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
{t('workflow.nodes.triggerSchedule.recurEvery')}
|
||||
</label>
|
||||
<InputNumber
|
||||
value={recurEvery}
|
||||
onChange={value => onRecurEveryChange(value || 1)}
|
||||
min={1}
|
||||
className="text-center"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
|
||||
</label>
|
||||
<SimpleSegmentedControl
|
||||
options={unitOptions}
|
||||
value={recurUnit}
|
||||
onChange={onRecurUnitChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecurConfig
|
||||
@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
|
||||
// Simplified version without icons
|
||||
type SimpleSegmentedControlProps<T extends string | number | symbol> = {
|
||||
options: { text: string, value: T }[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SimpleSegmentedControl = <T extends string | number | symbol>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SimpleSegmentedControlProps<T>) => {
|
||||
const selectedOptionIndex = options.findIndex(option => option.value === value)
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex items-center gap-x-[1px] rounded-lg bg-components-segmented-control-bg-normal p-0.5',
|
||||
className,
|
||||
)}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedOptionIndex
|
||||
const isNextSelected = index === selectedOptionIndex - 1
|
||||
const isLast = index === options.length - 1
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
key={String(option.value)}
|
||||
className={classNames(
|
||||
'border-0.5 group relative flex flex-1 items-center justify-center gap-x-0.5 rounded-lg border-transparent px-2 py-1',
|
||||
isSelected
|
||||
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<span className={classNames(
|
||||
'system-sm-medium p-0.5 text-text-tertiary',
|
||||
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
|
||||
)}>
|
||||
{option.text}
|
||||
</span>
|
||||
{!isLast && !isSelected && !isNextSelected && (
|
||||
<div className='absolute right-[-1px] top-0 flex h-full items-center'>
|
||||
<Divider type='vertical' className='mx-0 h-3.5' />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SimpleSegmentedControl) as typeof SimpleSegmentedControl
|
||||
@ -0,0 +1,223 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import TimePicker from './time-picker'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'time.title.pickTime': 'Pick Time',
|
||||
'common.operation.now': 'Now',
|
||||
'common.operation.ok': 'OK',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('TimePicker', () => {
|
||||
const mockOnChange = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders with default value', () => {
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
})
|
||||
|
||||
test('renders with provided value', () => {
|
||||
render(<TimePicker value="2:30 PM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toBe('2:30 PM')
|
||||
})
|
||||
|
||||
test('opens picker when button is clicked', () => {
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Pick Time')).toBeInTheDocument()
|
||||
expect(screen.getByText('Now')).toBeInTheDocument()
|
||||
expect(screen.getByText('OK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('closes picker when clicking outside', () => {
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('Pick Time')).toBeInTheDocument()
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(screen.queryByText('Pick Time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('button text remains unchanged when selecting time without clicking OK', () => {
|
||||
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
const hourButton = screen.getByText('3')
|
||||
fireEvent.click(hourButton)
|
||||
|
||||
const minuteButton = screen.getByText('45')
|
||||
fireEvent.click(minuteButton)
|
||||
|
||||
const pmButton = screen.getByText('PM')
|
||||
fireEvent.click(pmButton)
|
||||
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
expect(button.textContent).toBe('11:30 AM')
|
||||
})
|
||||
|
||||
test('calls onChange when clicking OK button', () => {
|
||||
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const hourButton = screen.getByText('3')
|
||||
fireEvent.click(hourButton)
|
||||
|
||||
const minuteButton = screen.getByText('45')
|
||||
fireEvent.click(minuteButton)
|
||||
|
||||
const pmButton = screen.getByText('PM')
|
||||
fireEvent.click(pmButton)
|
||||
|
||||
const okButton = screen.getByText('OK')
|
||||
fireEvent.click(okButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('3:45 PM')
|
||||
})
|
||||
|
||||
test('calls onChange when clicking Now button', () => {
|
||||
const mockDate = new Date('2024-01-15T14:30:00')
|
||||
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate)
|
||||
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('2:30 PM')
|
||||
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('initializes picker with current value when opened', async () => {
|
||||
render(<TimePicker value="3:45 PM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const selectedHour = screen.getByText('3').closest('button')
|
||||
expect(selectedHour).toHaveClass('bg-gray-100')
|
||||
|
||||
const selectedMinute = screen.getByText('45').closest('button')
|
||||
expect(selectedMinute).toHaveClass('bg-gray-100')
|
||||
|
||||
const selectedPeriod = screen.getByText('PM').closest('button')
|
||||
expect(selectedPeriod).toHaveClass('bg-gray-100')
|
||||
})
|
||||
})
|
||||
|
||||
test('resets picker selection when reopening after closing without OK', async () => {
|
||||
render(<TimePicker value="11:30 AM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const hourButton = screen.getByText('3')
|
||||
fireEvent.click(hourButton)
|
||||
|
||||
const overlay = document.querySelector('.fixed.inset-0')
|
||||
fireEvent.click(overlay!)
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
const hourButtons = screen.getAllByText('11')
|
||||
const selectedHourButton = hourButtons.find(btn => btn.closest('button')?.classList.contains('bg-gray-100'))
|
||||
expect(selectedHourButton).toBeTruthy()
|
||||
|
||||
const notSelectedHour = screen.getByText('3').closest('button')
|
||||
expect(notSelectedHour).not.toHaveClass('bg-gray-100')
|
||||
})
|
||||
})
|
||||
|
||||
test('handles 12 AM/PM correctly in Now button', () => {
|
||||
const mockMidnight = new Date('2024-01-15T00:30:00')
|
||||
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockMidnight)
|
||||
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:30 AM')
|
||||
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('handles 12 PM correctly in Now button', () => {
|
||||
const mockNoon = new Date('2024-01-15T12:30:00')
|
||||
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockNoon)
|
||||
|
||||
render(<TimePicker onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
const nowButton = screen.getByText('Now')
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('12:30 PM')
|
||||
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('auto-scrolls to selected values when opened', async () => {
|
||||
const mockScrollIntoView = jest.fn()
|
||||
Element.prototype.scrollIntoView = mockScrollIntoView
|
||||
|
||||
render(<TimePicker value="8:45 PM" onChange={mockOnChange} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}, { timeout: 200 })
|
||||
|
||||
mockScrollIntoView.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,230 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiTimeLine } from '@remixicon/react'
|
||||
|
||||
const scrollbarHideStyles = {
|
||||
scrollbarWidth: 'none' as const,
|
||||
msOverflowStyle: 'none' as const,
|
||||
} as React.CSSProperties
|
||||
|
||||
type TimePickerProps = {
|
||||
value?: string
|
||||
onChange: (time: string) => void
|
||||
}
|
||||
|
||||
const TimePicker = ({ value = '11:30 AM', onChange }: TimePickerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedHour, setSelectedHour] = useState(11)
|
||||
const [selectedMinute, setSelectedMinute] = useState(30)
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'AM' | 'PM'>('AM')
|
||||
const hourContainerRef = useRef<HTMLDivElement>(null)
|
||||
const minuteContainerRef = useRef<HTMLDivElement>(null)
|
||||
const periodContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (value) {
|
||||
const match = value.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/)
|
||||
if (match) {
|
||||
setSelectedHour(Number.parseInt(match[1], 10))
|
||||
setSelectedMinute(Number.parseInt(match[2], 10))
|
||||
setSelectedPeriod(match[3] as 'AM' | 'PM')
|
||||
}
|
||||
}
|
||||
else {
|
||||
setSelectedHour(11)
|
||||
setSelectedMinute(30)
|
||||
setSelectedPeriod('AM')
|
||||
}
|
||||
}
|
||||
}, [isOpen, value])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
if (hourContainerRef.current) {
|
||||
const selectedHourElement = hourContainerRef.current.querySelector('.bg-state-base-active')
|
||||
if (selectedHourElement)
|
||||
selectedHourElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
if (minuteContainerRef.current) {
|
||||
const selectedMinuteElement = minuteContainerRef.current.querySelector('.bg-state-base-active')
|
||||
if (selectedMinuteElement)
|
||||
selectedMinuteElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
if (periodContainerRef.current) {
|
||||
const selectedPeriodElement = periodContainerRef.current.querySelector('.bg-state-base-active')
|
||||
if (selectedPeriodElement)
|
||||
selectedPeriodElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}, [isOpen, selectedHour, selectedMinute, selectedPeriod])
|
||||
|
||||
const hours = Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => i)
|
||||
const periods = ['AM', 'PM'] as const
|
||||
|
||||
// Create padding elements to ensure bottom options can scroll to top
|
||||
// Container shows 8 options (h-64), so we need 7 padding elements at bottom
|
||||
const createBottomPadding = () => Array.from({ length: 7 }, (_, i) => (
|
||||
<div key={`bottom-padding-${i}`} className="pointer-events-none h-8" />
|
||||
))
|
||||
|
||||
const handleNow = () => {
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
const minute = now.getMinutes()
|
||||
const period = hour >= 12 ? 'PM' : 'AM'
|
||||
let displayHour = hour
|
||||
if (hour === 0)
|
||||
displayHour = 12
|
||||
else if (hour > 12)
|
||||
displayHour = hour - 12
|
||||
|
||||
const timeString = `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`
|
||||
onChange(timeString)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleOK = () => {
|
||||
const timeString = `${selectedHour}:${selectedMinute.toString().padStart(2, '0')} ${selectedPeriod}`
|
||||
onChange(timeString)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex h-9 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-3 py-1.5 text-sm text-text-secondary hover:bg-components-input-bg-hover"
|
||||
>
|
||||
<span>{value}</span>
|
||||
<RiTimeLine className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-72 select-none rounded-xl border border-components-panel-border bg-components-panel-bg p-4 shadow-lg">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('time.title.pickTime')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 border-b border-components-panel-border-subtle" />
|
||||
|
||||
<div className="mb-4 flex gap-3">
|
||||
{/* Hours */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
ref={hourContainerRef}
|
||||
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={scrollbarHideStyles}
|
||||
data-testid="hour-selector"
|
||||
>
|
||||
{hours.map(hour => (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
|
||||
selectedHour === hour
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-state-base-hover'
|
||||
}`}
|
||||
onClick={() => setSelectedHour(hour)}
|
||||
>
|
||||
{hour}
|
||||
</button>
|
||||
))}
|
||||
{createBottomPadding()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minutes */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
ref={minuteContainerRef}
|
||||
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={scrollbarHideStyles}
|
||||
data-testid="minute-selector"
|
||||
>
|
||||
{minutes.map(minute => (
|
||||
<button
|
||||
key={minute}
|
||||
type="button"
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
|
||||
selectedMinute === minute
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-state-base-hover'
|
||||
}`}
|
||||
onClick={() => setSelectedMinute(minute)}
|
||||
>
|
||||
{minute.toString().padStart(2, '0')}
|
||||
</button>
|
||||
))}
|
||||
{createBottomPadding()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AM/PM */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
ref={periodContainerRef}
|
||||
className="h-64 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
||||
style={scrollbarHideStyles}
|
||||
>
|
||||
{periods.map(period => (
|
||||
<button
|
||||
key={period}
|
||||
type="button"
|
||||
className={`block w-full rounded-lg px-3 py-1.5 text-center text-sm transition-colors ${
|
||||
selectedPeriod === period
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-secondary hover:bg-state-base-hover'
|
||||
}`}
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
>
|
||||
{period}
|
||||
</button>
|
||||
))}
|
||||
{createBottomPadding()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-b border-components-panel-border-subtle" />
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNow}
|
||||
className="flex-1 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-1 text-sm font-medium text-text-accent hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
{t('common.operation.now')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOK}
|
||||
className="flex-1 rounded-lg bg-components-button-primary-bg px-3 py-1 text-sm font-medium text-white hover:bg-components-button-primary-bg-hover"
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePicker
|
||||
@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type WeekdaySelectorProps = {
|
||||
selectedDays: string[]
|
||||
onChange: (days: string[]) => void
|
||||
}
|
||||
|
||||
const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const weekdays = [
|
||||
{ key: 'sun', label: 'Sun' },
|
||||
{ key: 'mon', label: 'Mon' },
|
||||
{ key: 'tue', label: 'Tue' },
|
||||
{ key: 'wed', label: 'Wed' },
|
||||
{ key: 'thu', label: 'Thu' },
|
||||
{ key: 'fri', label: 'Fri' },
|
||||
{ key: 'sat', label: 'Sat' },
|
||||
]
|
||||
|
||||
const selectedDay = selectedDays.length > 0 ? selectedDays[0] : 'sun'
|
||||
|
||||
const handleDaySelect = (dayKey: string) => {
|
||||
onChange([dayKey])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||
{t('workflow.nodes.triggerSchedule.weekdays')}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
{weekdays.map(day => (
|
||||
<button
|
||||
key={day.key}
|
||||
type="button"
|
||||
className={`flex-1 rounded-lg py-1.5 text-xs transition-colors ${
|
||||
selectedDay === day.key
|
||||
? 'border-2 border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-components-input-border-normal border text-text-tertiary hover:border-components-input-border-hover hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() => handleDaySelect(day.key)}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeekdaySelector
|
||||
@ -0,0 +1,35 @@
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { ScheduleTriggerNodeType } from './types'
|
||||
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
|
||||
|
||||
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
||||
defaultValue: {
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
cron_expression: '',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
enabled: true,
|
||||
},
|
||||
getAvailablePrevNodes(_isChatMode: boolean) {
|
||||
return []
|
||||
},
|
||||
getAvailableNextNodes(isChatMode: boolean) {
|
||||
const nodes = isChatMode
|
||||
? ALL_CHAT_AVAILABLE_BLOCKS
|
||||
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
|
||||
return nodes.filter(type => type !== BlockEnum.Start)
|
||||
},
|
||||
checkValid(_payload: ScheduleTriggerNodeType, _t: any) {
|
||||
return {
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
27
web/app/components/workflow/nodes/trigger-schedule/node.tsx
Normal file
27
web/app/components/workflow/nodes/trigger-schedule/node.tsx
Normal 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)
|
||||
166
web/app/components/workflow/nodes/trigger-schedule/panel.tsx
Normal file
166
web/app/components/workflow/nodes/trigger-schedule/panel.tsx
Normal 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)
|
||||
24
web/app/components/workflow/nodes/trigger-schedule/types.ts
Normal file
24
web/app/components/workflow/nodes/trigger-schedule/types.ts
Normal 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
|
||||
}
|
||||
106
web/app/components/workflow/nodes/trigger-schedule/use-config.ts
Normal file
106
web/app/components/workflow/nodes/trigger-schedule/use-config.ts
Normal 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
|
||||
@ -0,0 +1,233 @@
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
|
||||
describe('cron-parser', () => {
|
||||
describe('isValidCronExpression', () => {
|
||||
test('validates correct cron expressions', () => {
|
||||
expect(isValidCronExpression('15 10 1 * *')).toBe(true)
|
||||
expect(isValidCronExpression('0 0 * * 0')).toBe(true)
|
||||
expect(isValidCronExpression('*/5 * * * *')).toBe(true)
|
||||
expect(isValidCronExpression('0 9-17 * * 1-5')).toBe(true)
|
||||
expect(isValidCronExpression('30 14 * * 1')).toBe(true)
|
||||
expect(isValidCronExpression('0 0 1,15 * *')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects invalid cron expressions', () => {
|
||||
expect(isValidCronExpression('')).toBe(false)
|
||||
expect(isValidCronExpression('15 10 1')).toBe(false) // Not enough fields
|
||||
expect(isValidCronExpression('15 10 1 * * *')).toBe(false) // Too many fields
|
||||
expect(isValidCronExpression('60 10 1 * *')).toBe(false) // Invalid minute
|
||||
expect(isValidCronExpression('15 25 1 * *')).toBe(false) // Invalid hour
|
||||
expect(isValidCronExpression('15 10 32 * *')).toBe(false) // Invalid day
|
||||
expect(isValidCronExpression('15 10 1 13 *')).toBe(false) // Invalid month
|
||||
expect(isValidCronExpression('15 10 1 * 7')).toBe(false) // Invalid day of week
|
||||
})
|
||||
|
||||
test('handles edge cases', () => {
|
||||
expect(isValidCronExpression(' 15 10 1 * * ')).toBe(true) // Whitespace
|
||||
expect(isValidCronExpression('0 0 29 2 *')).toBe(true) // Feb 29 (valid in leap years)
|
||||
expect(isValidCronExpression('59 23 31 12 6')).toBe(true) // Max values
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCronExpression', () => {
|
||||
beforeEach(() => {
|
||||
// Mock current time to make tests deterministic
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('parses monthly expressions correctly', () => {
|
||||
const result = parseCronExpression('15 10 1 * *') // 1st day of every month at 10:15
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
expect(result[0].getDate()).toBe(1) // February 1st
|
||||
expect(result[0].getHours()).toBe(10)
|
||||
expect(result[0].getMinutes()).toBe(15)
|
||||
expect(result[1].getDate()).toBe(1) // March 1st
|
||||
expect(result[2].getDate()).toBe(1) // April 1st
|
||||
})
|
||||
|
||||
test('parses weekly expressions correctly', () => {
|
||||
const result = parseCronExpression('30 14 * * 1') // Every Monday at 14:30
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
// Should find next 5 Mondays
|
||||
result.forEach((date) => {
|
||||
expect(date.getDay()).toBe(1) // Monday
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('parses daily expressions correctly', () => {
|
||||
const result = parseCronExpression('0 9 * * *') // Every day at 9:00
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(9)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
// Should be consecutive days (starting from tomorrow since current time is 10:00)
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const prevDate = new Date(result[i - 1])
|
||||
const currDate = new Date(result[i])
|
||||
const dayDiff = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
expect(dayDiff).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
test('handles complex cron expressions with ranges', () => {
|
||||
const result = parseCronExpression('0 9-17 * * 1-5') // Weekdays, 9-17 hours
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDay()).toBeGreaterThanOrEqual(1) // Monday
|
||||
expect(date.getDay()).toBeLessThanOrEqual(5) // Friday
|
||||
expect(date.getHours()).toBeGreaterThanOrEqual(9)
|
||||
expect(date.getHours()).toBeLessThanOrEqual(17)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles step expressions', () => {
|
||||
const result = parseCronExpression('*/15 * * * *') // Every 15 minutes
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getMinutes() % 15).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles list expressions', () => {
|
||||
const result = parseCronExpression('0 0 1,15 * *') // 1st and 15th of each month
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect([1, 15]).toContain(date.getDate())
|
||||
expect(date.getHours()).toBe(0)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles expressions that span multiple months', () => {
|
||||
// Test with an expression that might not have many matches in current month
|
||||
const result = parseCronExpression('0 12 29 * *') // 29th of each month at noon
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.length).toBeLessThanOrEqual(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDate()).toBe(29)
|
||||
expect(date.getHours()).toBe(12)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('returns empty array for invalid expressions', () => {
|
||||
expect(parseCronExpression('')).toEqual([])
|
||||
expect(parseCronExpression('invalid')).toEqual([])
|
||||
expect(parseCronExpression('60 10 1 * *')).toEqual([])
|
||||
expect(parseCronExpression('15 25 1 * *')).toEqual([])
|
||||
})
|
||||
|
||||
test('handles edge case: February 29th in non-leap years', () => {
|
||||
// Set to a non-leap year
|
||||
jest.setSystemTime(new Date('2023-01-15T10:00:00Z'))
|
||||
|
||||
const result = parseCronExpression('0 12 29 2 *') // Feb 29th at noon
|
||||
|
||||
// Should return empty or skip 2023 and find 2024
|
||||
if (result.length > 0) {
|
||||
result.forEach((date) => {
|
||||
expect(date.getMonth()).toBe(1) // February
|
||||
expect(date.getDate()).toBe(29)
|
||||
// Should be in a leap year
|
||||
const year = date.getFullYear()
|
||||
expect(year % 4).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('sorts results chronologically', () => {
|
||||
const result = parseCronExpression('0 */6 * * *') // Every 6 hours
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
for (let i = 1; i < result.length; i++)
|
||||
expect(result[i].getTime()).toBeGreaterThan(result[i - 1].getTime())
|
||||
})
|
||||
|
||||
test('excludes past times', () => {
|
||||
// Set current time to 15:30
|
||||
jest.setSystemTime(new Date('2024-01-15T15:30:00Z'))
|
||||
|
||||
const result = parseCronExpression('0 10 * * *') // Daily at 10:00
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getTime()).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
// First result should be tomorrow since today's 10:00 has passed
|
||||
expect(result[0].getDate()).toBe(16)
|
||||
})
|
||||
|
||||
test('handles midnight expressions correctly', () => {
|
||||
const result = parseCronExpression('0 0 * * *') // Daily at midnight
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(0)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles year boundary correctly', () => {
|
||||
// Set to end of December
|
||||
jest.setSystemTime(new Date('2024-12-30T10:00:00Z'))
|
||||
|
||||
const result = parseCronExpression('0 12 1 * *') // 1st of every month at noon
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
// Should include January 1st of next year
|
||||
const nextYear = result.find(date => date.getFullYear() === 2025)
|
||||
expect(nextYear).toBeDefined()
|
||||
if (nextYear) {
|
||||
expect(nextYear.getMonth()).toBe(0) // January
|
||||
expect(nextYear.getDate()).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance tests', () => {
|
||||
test('performs well for complex expressions', () => {
|
||||
const start = performance.now()
|
||||
|
||||
// Test multiple complex expressions
|
||||
const expressions = [
|
||||
'*/5 9-17 * * 1-5', // Every 5 minutes, weekdays, business hours
|
||||
'0 */2 1,15 * *', // Every 2 hours on 1st and 15th
|
||||
'30 14 * * 1,3,5', // Mon, Wed, Fri at 14:30
|
||||
'15,45 8-18 * * 1-5', // 15 and 45 minutes past the hour, weekdays
|
||||
]
|
||||
|
||||
expressions.forEach((expr) => {
|
||||
const result = parseCronExpression(expr)
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
|
||||
// Test quarterly expression separately (may return fewer than 5 results)
|
||||
const quarterlyResult = parseCronExpression('0 0 1 */3 *') // First day of every 3rd month
|
||||
expect(quarterlyResult.length).toBeGreaterThan(0)
|
||||
expect(quarterlyResult.length).toBeLessThanOrEqual(5)
|
||||
|
||||
const end = performance.now()
|
||||
|
||||
// Should complete within reasonable time (less than 100ms for all expressions)
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
const matchesField = (value: number, pattern: string, min: number, max: number): boolean => {
|
||||
if (pattern === '*') return true
|
||||
|
||||
if (pattern.includes(','))
|
||||
return pattern.split(',').some(p => matchesField(value, p.trim(), min, max))
|
||||
|
||||
if (pattern.includes('/')) {
|
||||
const [range, step] = pattern.split('/')
|
||||
const stepValue = Number.parseInt(step, 10)
|
||||
if (Number.isNaN(stepValue)) return false
|
||||
|
||||
if (range === '*') {
|
||||
return value % stepValue === min % stepValue
|
||||
}
|
||||
else {
|
||||
const rangeStart = Number.parseInt(range, 10)
|
||||
if (Number.isNaN(rangeStart)) return false
|
||||
return value >= rangeStart && (value - rangeStart) % stepValue === 0
|
||||
}
|
||||
}
|
||||
|
||||
if (pattern.includes('-')) {
|
||||
const [start, end] = pattern.split('-').map(p => Number.parseInt(p.trim(), 10))
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return false
|
||||
return value >= start && value <= end
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(pattern, 10)
|
||||
if (Number.isNaN(numValue)) return false
|
||||
return value === numValue
|
||||
}
|
||||
|
||||
const expandCronField = (field: string, min: number, max: number): number[] => {
|
||||
if (field === '*')
|
||||
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
||||
|
||||
if (field.includes(','))
|
||||
return field.split(',').flatMap(p => expandCronField(p.trim(), min, max))
|
||||
|
||||
if (field.includes('/')) {
|
||||
const [range, step] = field.split('/')
|
||||
const stepValue = Number.parseInt(step, 10)
|
||||
if (Number.isNaN(stepValue)) return []
|
||||
|
||||
const baseValues = range === '*' ? [min] : expandCronField(range, min, max)
|
||||
const result: number[] = []
|
||||
|
||||
for (let start = baseValues[0]; start <= max; start += stepValue) {
|
||||
if (start >= min && start <= max)
|
||||
result.push(start)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (field.includes('-')) {
|
||||
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return []
|
||||
|
||||
const result: number[] = []
|
||||
for (let i = start; i <= end && i <= max; i++)
|
||||
if (i >= min) result.push(i)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(field, 10)
|
||||
return !Number.isNaN(numValue) && numValue >= min && numValue <= max ? [numValue] : []
|
||||
}
|
||||
|
||||
const matchesCron = (
|
||||
date: Date,
|
||||
minute: string,
|
||||
hour: string,
|
||||
dayOfMonth: string,
|
||||
month: string,
|
||||
dayOfWeek: string,
|
||||
): boolean => {
|
||||
const currentMinute = date.getMinutes()
|
||||
const currentHour = date.getHours()
|
||||
const currentDay = date.getDate()
|
||||
const currentMonth = date.getMonth() + 1
|
||||
const currentDayOfWeek = date.getDay()
|
||||
|
||||
// Basic time matching
|
||||
if (!matchesField(currentMinute, minute, 0, 59)) return false
|
||||
if (!matchesField(currentHour, hour, 0, 23)) return false
|
||||
if (!matchesField(currentMonth, month, 1, 12)) return false
|
||||
|
||||
// Day matching logic: if both dayOfMonth and dayOfWeek are specified (not *),
|
||||
// the cron should match if EITHER condition is true (OR logic)
|
||||
const dayOfMonthSpecified = dayOfMonth !== '*'
|
||||
const dayOfWeekSpecified = dayOfWeek !== '*'
|
||||
|
||||
if (dayOfMonthSpecified && dayOfWeekSpecified) {
|
||||
// If both are specified, match if either matches
|
||||
return matchesField(currentDay, dayOfMonth, 1, 31)
|
||||
|| matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
|
||||
}
|
||||
else if (dayOfMonthSpecified) {
|
||||
// Only day of month specified
|
||||
return matchesField(currentDay, dayOfMonth, 1, 31)
|
||||
}
|
||||
else if (dayOfWeekSpecified) {
|
||||
// Only day of week specified
|
||||
return matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
|
||||
}
|
||||
else {
|
||||
// Both are *, matches any day
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const parseCronExpression = (cronExpression: string): Date[] => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return []
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5)
|
||||
return []
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
|
||||
try {
|
||||
const nextTimes: Date[] = []
|
||||
const now = new Date()
|
||||
|
||||
// Start from next minute
|
||||
const startTime = new Date(now)
|
||||
startTime.setMinutes(startTime.getMinutes() + 1)
|
||||
startTime.setSeconds(0, 0)
|
||||
|
||||
// For monthly expressions (like "15 10 1 * *"), we need to check more months
|
||||
// For weekly expressions, we need to check more weeks
|
||||
// Use a smarter approach: check up to 12 months for monthly patterns
|
||||
const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*'
|
||||
const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*'
|
||||
|
||||
let searchMonths = 12
|
||||
if (isWeeklyPattern) searchMonths = 3 // 3 months should cover 12+ weeks
|
||||
else if (!isMonthlyPattern) searchMonths = 2 // For daily/hourly patterns
|
||||
|
||||
// Check across multiple months
|
||||
for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) {
|
||||
const checkMonth = new Date(startTime.getFullYear(), startTime.getMonth() + monthOffset, 1)
|
||||
|
||||
// Get the number of days in this month
|
||||
const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate()
|
||||
|
||||
// Check each day in this month
|
||||
for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) {
|
||||
const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day)
|
||||
|
||||
// For each day, check the specific hour and minute from cron
|
||||
// This is more efficient than checking all hours/minutes
|
||||
if (minute !== '*' && hour !== '*') {
|
||||
// Extract specific minute and hour values
|
||||
const minuteValues = expandCronField(minute, 0, 59)
|
||||
const hourValues = expandCronField(hour, 0, 23)
|
||||
|
||||
for (const h of hourValues) {
|
||||
for (const m of minuteValues) {
|
||||
checkDate.setHours(h, m, 0, 0)
|
||||
|
||||
// Skip if this time is before our start time
|
||||
if (checkDate <= now) continue
|
||||
|
||||
if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
||||
nextTimes.push(new Date(checkDate))
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback for complex expressions with wildcards
|
||||
for (let h = 0; h < 24 && nextTimes.length < 5; h++) {
|
||||
for (let m = 0; m < 60 && nextTimes.length < 5; m++) {
|
||||
checkDate.setHours(h, m, 0, 0)
|
||||
|
||||
if (checkDate <= now) continue
|
||||
|
||||
if (matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
||||
nextTimes.push(new Date(checkDate))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5)
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const isValidCronField = (field: string, min: number, max: number): boolean => {
|
||||
if (field === '*') return true
|
||||
|
||||
if (field.includes(','))
|
||||
return field.split(',').every(p => isValidCronField(p.trim(), min, max))
|
||||
|
||||
if (field.includes('/')) {
|
||||
const [range, step] = field.split('/')
|
||||
const stepValue = Number.parseInt(step, 10)
|
||||
if (Number.isNaN(stepValue) || stepValue <= 0) return false
|
||||
|
||||
if (range === '*') return true
|
||||
return isValidCronField(range, min, max)
|
||||
}
|
||||
|
||||
if (field.includes('-')) {
|
||||
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return false
|
||||
return start >= min && end <= max && start <= end
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(field, 10)
|
||||
return !Number.isNaN(numValue) && numValue >= min && numValue <= max
|
||||
}
|
||||
|
||||
export const isValidCronExpression = (cronExpression: string): boolean => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return false
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5)
|
||||
return false
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
|
||||
return (
|
||||
isValidCronField(minute, 0, 59)
|
||||
&& isValidCronField(hour, 0, 23)
|
||||
&& isValidCronField(dayOfMonth, 1, 31)
|
||||
&& isValidCronField(month, 1, 12)
|
||||
&& isValidCronField(dayOfWeek, 0, 6)
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,795 @@
|
||||
import { formatExecutionTime, getDefaultDateTime, getFormattedExecutionTimes, getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
|
||||
const createMockData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||
id: 'test-node',
|
||||
type: 'schedule-trigger',
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
weekdays: ['sun'],
|
||||
recur_every: 1,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Use system timezone for consistent tests
|
||||
enabled: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('execution-time-calculator', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 0, 0)) // Local time: 2024-01-15 10:00:00
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
describe('formatExecutionTime', () => {
|
||||
test('formats time with weekday by default', () => {
|
||||
const date = new Date(2024, 0, 16, 14, 30)
|
||||
const result = formatExecutionTime(date)
|
||||
|
||||
expect(result).toBe('Tue, January 16, 2024 2:30 PM')
|
||||
})
|
||||
|
||||
test('formats time without weekday when specified', () => {
|
||||
const date = new Date(2024, 0, 16, 14, 30)
|
||||
const result = formatExecutionTime(date, false)
|
||||
|
||||
expect(result).toBe('January 16, 2024 2:30 PM')
|
||||
})
|
||||
|
||||
test('handles morning times correctly', () => {
|
||||
const date = new Date(2024, 0, 16, 9, 15)
|
||||
const result = formatExecutionTime(date)
|
||||
|
||||
expect(result).toBe('Tue, January 16, 2024 9:15 AM')
|
||||
})
|
||||
|
||||
test('handles midnight correctly', () => {
|
||||
const date = new Date(2024, 0, 16, 0, 0)
|
||||
const result = formatExecutionTime(date)
|
||||
|
||||
expect(result).toBe('Tue, January 16, 2024 12:00 AM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - daily frequency', () => {
|
||||
test('calculates next 5 daily executions', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
expect(result[0].getHours()).toBe(14)
|
||||
expect(result[0].getMinutes()).toBe(30)
|
||||
expect(result[1].getDate()).toBe(result[0].getDate() + 1)
|
||||
})
|
||||
|
||||
test('handles past time by moving to next day', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // 3:00 PM local time
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getDate()).toBe(16)
|
||||
})
|
||||
|
||||
test('handles AM/PM conversion correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '11:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getHours()).toBe(23)
|
||||
expect(result[0].getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
test('handles 12 AM correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '12:00 AM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getHours()).toBe(0)
|
||||
})
|
||||
|
||||
test('handles 12 PM correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '12:00 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getHours()).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - weekly frequency', () => {
|
||||
test('calculates next 5 weekly executions for Sunday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDay()).toBe(0)
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
test('calculates next execution for Monday from Monday', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 10, 0))
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['mon'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result[0].getDate()).toBe(15)
|
||||
expect(result[1].getDate()).toBe(22)
|
||||
})
|
||||
|
||||
test('moves to next week when current day time has passed', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 15, 0, 0)) // Monday 3:00 PM local time
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['mon'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getDate()).toBe(22)
|
||||
})
|
||||
|
||||
test('handles different weekdays correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '9:00 AM',
|
||||
weekdays: ['fri'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getDay()).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - hourly frequency', () => {
|
||||
test('calculates hourly intervals correctly', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 2,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getTime() - startTime.getTime()).toBe(2 * 60 * 60 * 1000)
|
||||
expect(result[1].getTime() - startTime.getTime()).toBe(4 * 60 * 60 * 1000)
|
||||
expect(result[2].getTime() - startTime.getTime()).toBe(6 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
test('calculates minute intervals correctly', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 30,
|
||||
recur_unit: 'minutes',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getTime() - startTime.getTime()).toBe(30 * 60 * 1000)
|
||||
expect(result[1].getTime() - startTime.getTime()).toBe(60 * 60 * 1000)
|
||||
})
|
||||
|
||||
test('handles past start time by calculating next interval', () => {
|
||||
jest.setSystemTime(new Date(2024, 0, 15, 14, 30, 0)) // Local time 2:30 PM
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0) // Local time 12:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 1,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result[0].getHours()).toBe(15)
|
||||
expect(result[1].getHours()).toBe(16)
|
||||
})
|
||||
|
||||
test('uses current time as default start time', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
recur_every: 1,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0].getTime()).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
test('minute intervals should not have duplicates when recur_every changes', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0)
|
||||
|
||||
// Test with recur_every = 2 minutes
|
||||
const data2 = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 2,
|
||||
recur_unit: 'minutes',
|
||||
},
|
||||
})
|
||||
|
||||
const result2 = getNextExecutionTimes(data2, 5)
|
||||
|
||||
// Check for no duplicates in result2
|
||||
const timestamps2 = result2.map(date => date.getTime())
|
||||
const uniqueTimestamps2 = new Set(timestamps2)
|
||||
expect(timestamps2.length).toBe(uniqueTimestamps2.size)
|
||||
|
||||
// Check intervals are correct for 2-minute intervals
|
||||
for (let i = 1; i < result2.length; i++) {
|
||||
const timeDiff = result2[i].getTime() - result2[i - 1].getTime()
|
||||
expect(timeDiff).toBe(2 * 60 * 1000) // 2 minutes in milliseconds
|
||||
}
|
||||
})
|
||||
|
||||
test('hourly intervals should handle recur_every changes correctly', () => {
|
||||
const startTime = new Date(2024, 0, 15, 12, 0, 0)
|
||||
|
||||
// Test with recur_every = 3 hours
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: startTime.toISOString(),
|
||||
recur_every: 3,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 4)
|
||||
|
||||
// Check for no duplicates
|
||||
const timestamps = result.map(date => date.getTime())
|
||||
const uniqueTimestamps = new Set(timestamps)
|
||||
expect(timestamps.length).toBe(uniqueTimestamps.size)
|
||||
|
||||
// Check intervals are correct for 3-hour intervals
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const timeDiff = result[i].getTime() - result[i - 1].getTime()
|
||||
expect(timeDiff).toBe(3 * 60 * 60 * 1000) // 3 hours in milliseconds
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - cron mode', () => {
|
||||
test('uses cron parser for cron expressions', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: '0 12 * * *',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(12)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('returns empty array for invalid cron expression', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('returns empty array for missing cron expression', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: '',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - once frequency', () => {
|
||||
test('returns selected datetime for once frequency', () => {
|
||||
const selectedTime = new Date(2024, 0, 20, 15, 30, 0) // January 20, 2024 3:30 PM
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {
|
||||
datetime: selectedTime.toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].getTime()).toBe(selectedTime.getTime())
|
||||
})
|
||||
|
||||
test('returns empty array when no datetime selected for once frequency', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - fallback behavior', () => {
|
||||
test('handles unknown frequency by returning next days', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'unknown' as any,
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getDate()).toBe(16)
|
||||
expect(result[1].getDate()).toBe(17)
|
||||
expect(result[2].getDate()).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFormattedExecutionTimes', () => {
|
||||
test('formats daily execution times without weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
expect(result[0]).toMatch(/January \d+, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('formats weekly execution times with weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatch(/^Sun, January \d+, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('formats hourly execution times without weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {
|
||||
datetime: new Date(2024, 0, 16, 14, 0, 0).toISOString(), // Local time 2:00 PM
|
||||
recur_every: 2,
|
||||
recur_unit: 'hours',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 1)
|
||||
|
||||
expect(result[0]).toMatch(/January 16, 2024 4:00 PM/)
|
||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
})
|
||||
|
||||
test('returns empty array when no execution times', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid',
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTime', () => {
|
||||
test('returns first formatted execution time', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January \d+, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('returns current time when no execution times available for non-once frequencies', () => {
|
||||
const data = createMockData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid',
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January 15, 2024 10:00 AM/)
|
||||
})
|
||||
|
||||
test('returns default datetime for once frequency when no datetime configured', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January 16, 2024 11:30 AM/)
|
||||
})
|
||||
|
||||
test('returns configured datetime for once frequency when available', () => {
|
||||
const selectedTime = new Date(2024, 0, 20, 15, 30, 0)
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {
|
||||
datetime: selectedTime.toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTime(data)
|
||||
|
||||
expect(result).toMatch(/January 20, 2024 3:30 PM/)
|
||||
})
|
||||
|
||||
test('applies correct weekday formatting based on frequency', () => {
|
||||
const weeklyData = createMockData({
|
||||
frequency: 'weekly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
weekdays: ['sun'],
|
||||
},
|
||||
})
|
||||
|
||||
const dailyData = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const weeklyResult = getNextExecutionTime(weeklyData)
|
||||
const dailyResult = getNextExecutionTime(dailyData)
|
||||
|
||||
expect(weeklyResult).toMatch(/^Sun,/)
|
||||
expect(dailyResult).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
test('handles missing visual_config gracefully', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: undefined,
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('uses default values for missing config properties', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'hourly',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 1)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('handles malformed time strings gracefully', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: 'invalid time' },
|
||||
})
|
||||
|
||||
expect(() => getNextExecutionTimes(data, 1)).not.toThrow()
|
||||
})
|
||||
|
||||
test('returns reasonable defaults for zero count', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 0)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('daily frequency should not have duplicate dates', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'daily',
|
||||
visual_config: { time: '2:30 PM' },
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 5)
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
|
||||
// Check that each date is unique and consecutive
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
const prevDate = result[i - 1].getDate()
|
||||
const currDate = result[i].getDate()
|
||||
expect(currDate).not.toBe(prevDate) // No duplicates
|
||||
expect(currDate - prevDate).toBe(1) // Should be consecutive days
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNextExecutionTimes - monthly frequency', () => {
|
||||
test('returns monthly execution times for specific day', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDate()).toBe(15)
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
expect(result[0].getMonth()).toBe(0) // January
|
||||
expect(result[1].getMonth()).toBe(1) // February
|
||||
expect(result[2].getMonth()).toBe(2) // March
|
||||
})
|
||||
|
||||
test('returns monthly execution times for last day', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
monthly_day: 'last',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 4)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(11)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
expect(result[0].getDate()).toBe(31) // January 31
|
||||
expect(result[1].getDate()).toBe(29) // February 29 (2024 is leap year)
|
||||
expect(result[2].getDate()).toBe(31) // March 31
|
||||
expect(result[3].getDate()).toBe(30) // April 30
|
||||
})
|
||||
|
||||
test('handles day 31 in months with fewer days', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '3:00 PM',
|
||||
monthly_day: 31,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 4)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].getDate()).toBe(31) // January 31
|
||||
expect(result[1].getDate()).toBe(29) // February 29 (can't have 31)
|
||||
expect(result[2].getDate()).toBe(31) // March 31
|
||||
expect(result[3].getDate()).toBe(30) // April 30 (can't have 31)
|
||||
})
|
||||
|
||||
test('handles day 30 in February', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '9:00 AM',
|
||||
monthly_day: 30,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getDate()).toBe(30) // January 30
|
||||
expect(result[1].getDate()).toBe(29) // February 29 (max in 2024)
|
||||
expect(result[2].getDate()).toBe(30) // March 30
|
||||
})
|
||||
|
||||
test('skips to next month if current month execution has passed', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2024, 0, 20, 15, 0, 0)) // January 20, 2024 3:00 PM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15, // Already passed in January
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getMonth()).toBe(1) // February (skip January)
|
||||
expect(result[1].getMonth()).toBe(2) // March
|
||||
expect(result[2].getMonth()).toBe(3) // April
|
||||
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('includes current month if execution time has not passed', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2024, 0, 10, 10, 0, 0)) // January 10, 2024 10:00 AM
|
||||
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15, // Still upcoming in January
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 3)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].getMonth()).toBe(0) // January (current month)
|
||||
expect(result[1].getMonth()).toBe(1) // February
|
||||
expect(result[2].getMonth()).toBe(2) // March
|
||||
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('handles AM/PM time conversion correctly', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '11:30 PM',
|
||||
monthly_day: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result.forEach((date) => {
|
||||
expect(date.getHours()).toBe(23) // 11 PM in 24-hour format
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
expect(date.getDate()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('formats monthly execution times without weekday', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '2:30 PM',
|
||||
monthly_day: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const result = getFormattedExecutionTimes(data, 1)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
|
||||
expect(result[0]).toMatch(/January 15, 2024 2:30 PM/)
|
||||
})
|
||||
|
||||
test('uses default day 1 when monthly_day is not specified', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '10:00 AM',
|
||||
},
|
||||
})
|
||||
|
||||
const result = getNextExecutionTimes(data, 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDate()).toBe(1)
|
||||
expect(date.getHours()).toBe(10)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultDateTime', () => {
|
||||
test('returns consistent default datetime', () => {
|
||||
const defaultDate = getDefaultDateTime()
|
||||
|
||||
expect(defaultDate.getHours()).toBe(11)
|
||||
expect(defaultDate.getMinutes()).toBe(30)
|
||||
expect(defaultDate.getSeconds()).toBe(0)
|
||||
expect(defaultDate.getMilliseconds()).toBe(0)
|
||||
expect(defaultDate.getDate()).toBe(new Date().getDate() + 1)
|
||||
})
|
||||
|
||||
test('default datetime matches DateTimePicker fallback behavior', () => {
|
||||
const data = createMockData({
|
||||
frequency: 'once',
|
||||
visual_config: {},
|
||||
})
|
||||
|
||||
const nextExecutionTime = getNextExecutionTime(data)
|
||||
const defaultDate = getDefaultDateTime()
|
||||
const expectedFormat = formatExecutionTime(defaultDate, false)
|
||||
|
||||
expect(nextExecutionTime).toBe(expectedFormat)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,200 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
|
||||
// Helper function to get current time - timezone is handled by Date object natively
|
||||
const getCurrentTime = (): Date => {
|
||||
return new Date()
|
||||
}
|
||||
|
||||
// Helper function to get default datetime for once/hourly modes - consistent with DateTimePicker
|
||||
export const getDefaultDateTime = (): Date => {
|
||||
const defaultDate = new Date()
|
||||
defaultDate.setHours(11, 30, 0, 0)
|
||||
defaultDate.setDate(defaultDate.getDate() + 1)
|
||||
return defaultDate
|
||||
}
|
||||
|
||||
export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): Date[] => {
|
||||
if (data.mode === 'cron') {
|
||||
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
|
||||
return []
|
||||
return parseCronExpression(data.cron_expression).slice(0, count)
|
||||
}
|
||||
|
||||
const times: Date[] = []
|
||||
const defaultTime = data.visual_config?.time || '11:30 AM'
|
||||
|
||||
if (data.frequency === 'hourly') {
|
||||
const recurEvery = data.visual_config?.recur_every || 1
|
||||
const recurUnit = data.visual_config?.recur_unit || 'hours'
|
||||
const startTime = data.visual_config?.datetime ? new Date(data.visual_config.datetime) : getCurrentTime()
|
||||
|
||||
const intervalMs = recurUnit === 'hours'
|
||||
? recurEvery * 60 * 60 * 1000
|
||||
: recurEvery * 60 * 1000
|
||||
|
||||
// Calculate the initial offset if start time has passed
|
||||
const now = getCurrentTime()
|
||||
let initialOffset = 0
|
||||
|
||||
if (startTime <= now) {
|
||||
const timeDiff = now.getTime() - startTime.getTime()
|
||||
initialOffset = Math.floor(timeDiff / intervalMs)
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextExecution = new Date(startTime.getTime() + (initialOffset + i + 1) * intervalMs)
|
||||
times.push(nextExecution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'daily') {
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||
|
||||
const now = getCurrentTime()
|
||||
const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
// Calculate initial offset: if time has passed today, start from tomorrow
|
||||
const initialOffset = baseExecution <= now ? 1 : 0
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextExecution = new Date(baseExecution)
|
||||
nextExecution.setDate(baseExecution.getDate() + initialOffset + i)
|
||||
times.push(nextExecution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'weekly') {
|
||||
const selectedDay = data.visual_config?.weekdays?.[0] || 'sun'
|
||||
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
|
||||
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
||||
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||
|
||||
const now = getCurrentTime()
|
||||
const currentDay = now.getDay()
|
||||
let daysUntilNext = (targetDay - currentDay + 7) % 7
|
||||
|
||||
const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
if (daysUntilNext === 0 && nextExecutionBase <= now)
|
||||
daysUntilNext = 7
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextExecution = new Date(nextExecutionBase)
|
||||
nextExecution.setDate(nextExecution.getDate() + daysUntilNext + (i * 7))
|
||||
times.push(nextExecution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'monthly') {
|
||||
const selectedDay = data.visual_config?.monthly_day || 1
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||
|
||||
const now = getCurrentTime()
|
||||
let monthOffset = 0
|
||||
|
||||
const currentMonthExecution = (() => {
|
||||
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
let targetDay: number
|
||||
|
||||
if (selectedDay === 'last') {
|
||||
const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
|
||||
targetDay = lastDayOfMonth
|
||||
}
|
||||
else {
|
||||
targetDay = Math.min(selectedDay as number, new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate())
|
||||
}
|
||||
|
||||
return new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
})()
|
||||
|
||||
if (currentMonthExecution <= now)
|
||||
monthOffset = 1
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + i, 1)
|
||||
let targetDay: number
|
||||
|
||||
if (selectedDay === 'last') {
|
||||
const lastDayOfMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||
targetDay = lastDayOfMonth
|
||||
}
|
||||
else {
|
||||
targetDay = Math.min(selectedDay as number, new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate())
|
||||
}
|
||||
|
||||
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
times.push(nextExecution)
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'once') {
|
||||
// For 'once' frequency, return the selected datetime
|
||||
const selectedDateTime = data.visual_config?.datetime
|
||||
if (selectedDateTime)
|
||||
times.push(new Date(selectedDateTime))
|
||||
}
|
||||
else {
|
||||
// Fallback for unknown frequencies
|
||||
for (let i = 0; i < count; i++) {
|
||||
const now = getCurrentTime()
|
||||
const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1)
|
||||
times.push(nextExecution)
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
export const formatExecutionTime = (date: Date, includeWeekday: boolean = true): string => {
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
|
||||
if (includeWeekday)
|
||||
dateOptions.weekday = 'short'
|
||||
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
}
|
||||
|
||||
// Always use local time for display to match calculation logic
|
||||
return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
|
||||
}
|
||||
|
||||
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
|
||||
const times = getNextExecutionTimes(data, count)
|
||||
|
||||
return times.map((date) => {
|
||||
// Only weekly frequency includes weekday in format
|
||||
const includeWeekday = data.frequency === 'weekly'
|
||||
return formatExecutionTime(date, includeWeekday)
|
||||
})
|
||||
}
|
||||
|
||||
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
||||
const times = getFormattedExecutionTimes(data, 1)
|
||||
if (times.length === 0) {
|
||||
if (data.frequency === 'once') {
|
||||
const defaultDate = getDefaultDateTime()
|
||||
return formatExecutionTime(defaultDate, false)
|
||||
}
|
||||
const now = getCurrentTime()
|
||||
const includeWeekday = data.frequency === 'weekly'
|
||||
return formatExecutionTime(now, includeWeekday)
|
||||
}
|
||||
return times[0]
|
||||
}
|
||||
Reference in New Issue
Block a user