fix: simplify trigger-schedule hourly mode calculation and improve UI consistency (#24082)

Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
This commit is contained in:
lyzno1 2025-08-18 23:37:57 +08:00 committed by GitHub
parent 5c4bf7aabd
commit 6a3d135d49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 320 additions and 591 deletions

View File

@ -36,6 +36,7 @@ const DatePicker = ({
renderTrigger,
triggerWrapClassName,
popupZIndexClassname = 'z-[11]',
notClearable = false,
}: DatePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
@ -200,7 +201,7 @@ const DatePicker = ({
<PortalToFollowElem
open={isOpen}
onOpenChange={setIsOpen}
placement='bottom-end'
placement='bottom-start'
>
<PortalToFollowElemTrigger className={triggerWrapClassName}>
{renderTrigger ? (renderTrigger({
@ -224,15 +225,17 @@ const DatePicker = ({
<RiCalendarLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
(displayValue || (isOpen && selectedDate)) && !notClearable && 'group-hover:hidden',
)} />
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
{!notClearable && (
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedDate)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
)}
</div>
)}
</PortalToFollowElemTrigger>

View File

@ -23,6 +23,7 @@ const TimePicker = ({
title,
minuteFilter,
popupClassName,
notClearable = false,
}: TimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
@ -123,7 +124,7 @@ const TimePicker = ({
<PortalToFollowElem
open={isOpen}
onOpenChange={setIsOpen}
placement='bottom-end'
placement='bottom-start'
>
<PortalToFollowElemTrigger>
{renderTrigger ? (renderTrigger({
@ -139,15 +140,17 @@ const TimePicker = ({
<RiTimeLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedTime)) && 'group-hover:hidden',
(displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden',
)} />
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedTime)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
{!notClearable && (
<RiCloseCircleFill
className={cn(
'hidden h-4 w-4 shrink-0 text-text-quaternary',
(displayValue || (isOpen && selectedTime)) && 'hover:text-text-secondary group-hover:inline-block',
)}
onClick={handleClear}
/>
)}
</div>
)}
</PortalToFollowElemTrigger>

View File

@ -30,6 +30,7 @@ export type DatePickerProps = {
renderTrigger?: (props: TriggerProps) => React.ReactNode
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
notClearable?: boolean
}
export type DatePickerHeaderProps = {
@ -63,6 +64,7 @@ export type TimePickerProps = {
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string
notClearable?: boolean
}
export type TimePickerFooterProps = {

View File

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

View File

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

View File

@ -27,7 +27,8 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
defaultValue={frequency}
onSelect={item => onChange(item.value as ScheduleFrequency)}
placeholder={t('workflow.nodes.triggerSchedule.selectFrequency')}
className="w-full"
className="w-full py-2"
wrapperClassName="h-auto"
optionWrapClassName="min-w-40"
notClearable={true}
allowSearch={false}

View File

@ -22,7 +22,7 @@ const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps
return (
<div className="space-y-2">
<label className="mb-2 block text-xs font-medium text-gray-500">
<label className="mb-2 block text-xs font-medium text-text-tertiary">
{t('workflow.nodes.triggerSchedule.days')}
</label>
@ -34,12 +34,12 @@ const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps
key={day}
type="button"
onClick={() => onChange(day)}
className={`rounded-lg py-1.5 text-xs transition-colors ${
className={`rounded-lg border bg-components-option-card-option-bg py-1 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'
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
}`}
>
{day === 'last' ? (

View File

@ -10,8 +10,8 @@ type NextExecutionTimesProps = {
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
const { t } = useTranslation()
// Don't show next execution times for 'once' frequency
if (data.frequency === 'once')
// Don't show next execution times for 'once' frequency in visual mode
if (data.mode === 'visual' && data.frequency === 'once')
return null
const executionTimes = getFormattedExecutionTimes(data, 5)

View File

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

View File

@ -27,7 +27,7 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
return (
<div className="space-y-2">
<label className="mb-2 block text-xs font-medium text-gray-500">
<label className="mb-2 block text-xs font-medium text-text-tertiary">
{t('workflow.nodes.triggerSchedule.weekdays')}
</label>
<div className="flex gap-1.5">
@ -35,10 +35,10 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
<button
key={day.key}
type="button"
className={`flex-1 rounded-lg py-1.5 text-xs transition-colors ${
className={`flex-1 rounded-lg border bg-components-option-card-option-bg py-1 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'
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
}`}
onClick={() => handleDaySelect(day.key)}
>

View File

@ -7,8 +7,9 @@ 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 TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
import dayjs from 'dayjs'
import NextExecutionTimes from './components/next-execution-times'
import ExecuteNowButton from './components/execute-now-button'
import RecurConfig from './components/recur-config'
@ -74,23 +75,49 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
}
</label>
{inputs.frequency === 'hourly' || inputs.frequency === 'once' ? (
<DateTimePicker
value={inputs.visual_config?.datetime}
onChange={(datetime) => {
<DatePicker
notClearable={true}
value={inputs.visual_config?.datetime ? dayjs(inputs.visual_config.datetime) : dayjs()}
onChange={(date) => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
datetime,
datetime: date ? date.toISOString() : undefined,
},
}
setInputs(newInputs)
}}
onClear={() => {
const newInputs = {
...inputs,
visual_config: {
...inputs.visual_config,
datetime: undefined,
},
}
setInputs(newInputs)
}}
placeholder={t('workflow.nodes.triggerSchedule.selectDateTime')}
needTimePicker={true}
/>
) : (
<TimePicker
value={inputs.visual_config?.time || '11:30 AM'}
onChange={handleTimeChange}
notClearable={true}
value={inputs.visual_config?.time
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
: dayjs('1/1/2000 11:30 AM')
}
onChange={(time) => {
if (time) {
const timeString = time.format('h:mm A')
handleTimeChange(timeString)
}
}}
onClear={() => {
handleTimeChange('11:30 AM')
}}
placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
/>
)}
</div>

View File

@ -33,6 +33,15 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
const newInputs = {
...inputs,
frequency,
visual_config: {
...inputs.visual_config,
...(frequency === 'hourly' || frequency === 'once') && !inputs.visual_config?.datetime && {
datetime: new Date().toISOString(),
},
...(frequency !== 'hourly' && frequency !== 'once') && {
datetime: undefined,
},
},
}
setInputs(newInputs)
}, [inputs, setInputs])

View File

@ -204,9 +204,9 @@ describe('execution-time-calculator', () => {
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)
expect(result[0].getTime() - startTime.getTime()).toBe(0) // Starts from baseTime
expect(result[1].getTime() - startTime.getTime()).toBe(2 * 60 * 60 * 1000)
expect(result[2].getTime() - startTime.getTime()).toBe(4 * 60 * 60 * 1000)
})
test('calculates minute intervals correctly', () => {
@ -224,11 +224,12 @@ describe('execution-time-calculator', () => {
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)
expect(result[0].getTime() - startTime.getTime()).toBe(0) // Starts from baseTime
expect(result[1].getTime() - startTime.getTime()).toBe(30 * 60 * 1000)
expect(result[2].getTime() - startTime.getTime()).toBe(60 * 60 * 1000)
})
test('handles past start time by calculating next interval', () => {
test('calculates intervals from baseTime regardless of current time', () => {
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
@ -243,11 +244,12 @@ describe('execution-time-calculator', () => {
const result = getNextExecutionTimes(data, 2)
expect(result[0].getHours()).toBe(15)
expect(result[1].getHours()).toBe(16)
// New logic: always starts from baseTime regardless of current time
expect(result[0].getHours()).toBe(12) // Starts from baseTime (12:00 PM)
expect(result[1].getHours()).toBe(13) // 1 hour after baseTime
})
test('uses current time as default start time', () => {
test('returns empty array when no datetime provided', () => {
const data = createMockData({
frequency: 'hourly',
visual_config: {
@ -258,7 +260,7 @@ describe('execution-time-calculator', () => {
const result = getNextExecutionTimes(data, 1)
expect(result[0].getTime()).toBeGreaterThan(Date.now())
expect(result).toHaveLength(0)
})
test('minute intervals should not have duplicates when recur_every changes', () => {
@ -314,6 +316,220 @@ describe('execution-time-calculator', () => {
expect(timeDiff).toBe(3 * 60 * 60 * 1000) // 3 hours in milliseconds
}
})
test('returns empty array when only time field provided (no datetime)', () => {
jest.setSystemTime(new Date(2024, 0, 15, 9, 0, 0)) // 9:00 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
recur_every: 1,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('prioritizes datetime over time field for hourly frequency', () => {
const specificDateTime = new Date(2024, 0, 15, 14, 15, 0)
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
datetime: specificDateTime.toISOString(),
recur_every: 2,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(2)
expect(result[0].getHours()).toBe(14) // Starts from datetime (14:15)
expect(result[0].getMinutes()).toBe(15)
expect(result[1].getHours()).toBe(16) // 2 hours after 14:15
expect(result[1].getMinutes()).toBe(15)
})
test('returns empty array when only time field provided (no datetime)', () => {
jest.setSystemTime(new Date(2024, 0, 15, 13, 0, 0)) // 1:00 PM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
recur_every: 2,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - PM test', () => {
jest.setSystemTime(new Date(2024, 0, 15, 9, 0, 0)) // 9:00 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '2:30 PM',
recur_every: 1,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - 12 AM test', () => {
jest.setSystemTime(new Date(2024, 0, 15, 22, 0, 0)) // 10:00 PM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '12:00 AM',
recur_every: 1,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - 12 PM test', () => {
jest.setSystemTime(new Date(2024, 0, 15, 9, 0, 0)) // 9:00 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '12:00 PM',
recur_every: 1,
recur_unit: 'hours',
},
})
const result = getNextExecutionTimes(data, 2)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - minute intervals', () => {
jest.setSystemTime(new Date(2024, 0, 15, 11, 0, 0)) // 11:00 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM', // User sees 11:30 AM in UI
recur_every: 1,
recur_unit: 'minutes',
},
})
const result = getNextExecutionTimes(data, 5)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - exact time test', () => {
// Set current time to exactly 11:30 AM
jest.setSystemTime(new Date(2024, 0, 15, 11, 30, 0)) // 11:30 AM exactly
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
recur_every: 1,
recur_unit: 'minutes',
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - 5 minute intervals', () => {
jest.setSystemTime(new Date(2024, 0, 15, 11, 0, 0)) // 11:00 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
recur_every: 5,
recur_unit: 'minutes',
},
})
const result = getNextExecutionTimes(data, 4)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - past times', () => {
// User sets time to 11:30 but current time is already 11:32
jest.setSystemTime(new Date(2024, 0, 15, 11, 32, 0)) // 11:32 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
recur_every: 1,
recur_unit: 'minutes',
},
})
const result = getNextExecutionTimes(data, 3)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when only time field provided (no datetime) - 3 minute intervals', () => {
jest.setSystemTime(new Date(2024, 0, 15, 11, 32, 0)) // 11:32 AM
const data = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM',
recur_every: 3, // every 3 minutes
recur_unit: 'minutes',
},
})
const result = getNextExecutionTimes(data, 4)
expect(result).toHaveLength(0) // New logic requires datetime field
})
test('returns empty array when no datetime field (frequency switch scenario)', () => {
jest.setSystemTime(new Date(2024, 0, 15, 11, 35, 0)) // 11:35 AM current time
// Simulate user switching FROM daily TO hourly
// Daily frequency only has time field, no datetime
const dataWithoutDatetime = createMockData({
frequency: 'hourly',
visual_config: {
time: '11:30 AM', // User sees this in UI
recur_every: 1,
recur_unit: 'minutes',
// NO datetime field - this is the bug scenario
},
})
const result = getNextExecutionTimes(dataWithoutDatetime, 5)
expect(result).toHaveLength(0) // New logic requires datetime field
})
})
describe('getNextExecutionTimes - cron mode', () => {
@ -439,7 +655,8 @@ describe('execution-time-calculator', () => {
const result = getFormattedExecutionTimes(data, 1)
expect(result[0]).toMatch(/January 16, 2024 4:00 PM/)
// New logic: starts from baseTime (2:00 PM), not baseTime + interval
expect(result[0]).toMatch(/January 16, 2024 2:00 PM/)
expect(result[0]).not.toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
})
@ -537,7 +754,7 @@ describe('execution-time-calculator', () => {
expect(result).toHaveLength(1)
})
test('uses default values for missing config properties', () => {
test('returns empty array for hourly when no datetime provided', () => {
const data = createMockData({
frequency: 'hourly',
visual_config: {},
@ -545,7 +762,7 @@ describe('execution-time-calculator', () => {
const result = getNextExecutionTimes(data, 1)
expect(result).toHaveLength(1)
expect(result).toHaveLength(0) // New logic requires datetime field for hourly
})
test('handles malformed time strings gracefully', () => {

View File

@ -6,7 +6,7 @@ const getCurrentTime = (): Date => {
return new Date()
}
// Helper function to get default datetime for once/hourly modes - consistent with DateTimePicker
// Helper function to get default datetime for once/hourly modes - consistent with base DatePicker
export const getDefaultDateTime = (): Date => {
const defaultDate = new Date()
defaultDate.setHours(11, 30, 0, 0)
@ -25,26 +25,20 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
const defaultTime = data.visual_config?.time || '11:30 AM'
if (data.frequency === 'hourly') {
const recurEvery = data.visual_config?.recur_every || 1
if (!data.visual_config?.datetime)
return []
const baseTime = new Date(data.visual_config.datetime)
const recurUnit = data.visual_config?.recur_unit || 'hours'
const startTime = data.visual_config?.datetime ? new Date(data.visual_config.datetime) : getCurrentTime()
const recurEvery = data.visual_config?.recur_every || 1
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)
const executionTime = new Date(baseTime.getTime() + i * intervalMs)
times.push(executionTime)
}
}
else if (data.frequency === 'daily') {