mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
refactor: comprehensive schedule trigger component redesign (#24359)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: zhangxuhe1 <xuhezhang6@gmail.com>
This commit is contained in:
@ -60,7 +60,7 @@ pnpm test # Run Jest tests
|
|||||||
- No `Any` types unless absolutely necessary
|
- No `Any` types unless absolutely necessary
|
||||||
- Implement special methods (`__repr__`, `__str__`) appropriately
|
- Implement special methods (`__repr__`, `__str__`) appropriately
|
||||||
|
|
||||||
### TypeScript/JavaScript
|
### TypeScript/JavaScript
|
||||||
|
|
||||||
- Strict TypeScript configuration
|
- Strict TypeScript configuration
|
||||||
- ESLint with Prettier integration
|
- ESLint with Prettier integration
|
||||||
@ -79,9 +79,9 @@ pnpm test # Run Jest tests
|
|||||||
### Adding a New API Endpoint
|
### Adding a New API Endpoint
|
||||||
|
|
||||||
1. Create controller in `/api/controllers/`
|
1. Create controller in `/api/controllers/`
|
||||||
2. Add service logic in `/api/services/`
|
1. Add service logic in `/api/services/`
|
||||||
3. Update routes in controller's `__init__.py`
|
1. Update routes in controller's `__init__.py`
|
||||||
4. Write tests in `/api/tests/`
|
1. Write tests in `/api/tests/`
|
||||||
|
|
||||||
## Project-Specific Conventions
|
## Project-Specific Conventions
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ describe('Schedule Trigger Node Default', () => {
|
|||||||
describe('Basic Configuration', () => {
|
describe('Basic Configuration', () => {
|
||||||
it('should have correct default value', () => {
|
it('should have correct default value', () => {
|
||||||
expect(nodeDefault.defaultValue.mode).toBe('visual')
|
expect(nodeDefault.defaultValue.mode).toBe('visual')
|
||||||
expect(nodeDefault.defaultValue.frequency).toBe('daily')
|
expect(nodeDefault.defaultValue.frequency).toBe('weekly')
|
||||||
expect(nodeDefault.defaultValue.enabled).toBe(true)
|
expect(nodeDefault.defaultValue.enabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,227 @@
|
|||||||
|
import { getNextExecutionTimes } from '../utils/execution-time-calculator'
|
||||||
|
import type { ScheduleTriggerNodeType } from '../types'
|
||||||
|
|
||||||
|
const createMonthlyConfig = (monthly_days: (number | 'last')[], time = '10:30 AM', timezone = 'UTC'): ScheduleTriggerNodeType => ({
|
||||||
|
mode: 'visual',
|
||||||
|
frequency: 'monthly',
|
||||||
|
visual_config: {
|
||||||
|
time,
|
||||||
|
monthly_days,
|
||||||
|
},
|
||||||
|
timezone,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Monthly Edge Cases', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
jest.setSystemTime(new Date('2024-02-15T08:00:00.000Z'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('31st day selection logic', () => {
|
||||||
|
test('31st day skips months without 31 days', () => {
|
||||||
|
const config = createMonthlyConfig([31])
|
||||||
|
const times = getNextExecutionTimes(config, 5)
|
||||||
|
|
||||||
|
const expectedMonths = times.map(date => date.getMonth() + 1)
|
||||||
|
|
||||||
|
expect(expectedMonths).not.toContain(2)
|
||||||
|
expect(expectedMonths).not.toContain(4)
|
||||||
|
expect(expectedMonths).not.toContain(6)
|
||||||
|
expect(expectedMonths).not.toContain(9)
|
||||||
|
expect(expectedMonths).not.toContain(11)
|
||||||
|
|
||||||
|
times.forEach((date) => {
|
||||||
|
expect(date.getDate()).toBe(31)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('30th day skips February', () => {
|
||||||
|
const config = createMonthlyConfig([30])
|
||||||
|
const times = getNextExecutionTimes(config, 5)
|
||||||
|
|
||||||
|
const expectedMonths = times.map(date => date.getMonth() + 1)
|
||||||
|
expect(expectedMonths).not.toContain(2)
|
||||||
|
|
||||||
|
times.forEach((date) => {
|
||||||
|
expect(date.getDate()).toBe(30)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('29th day works in all months', () => {
|
||||||
|
const config = createMonthlyConfig([29])
|
||||||
|
const times = getNextExecutionTimes(config, 12)
|
||||||
|
|
||||||
|
const months = times.map(date => date.getMonth() + 1)
|
||||||
|
expect(months).toContain(1)
|
||||||
|
expect(months).toContain(3)
|
||||||
|
expect(months).toContain(4)
|
||||||
|
expect(months).toContain(5)
|
||||||
|
expect(months).toContain(6)
|
||||||
|
expect(months).toContain(7)
|
||||||
|
expect(months).toContain(8)
|
||||||
|
expect(months).toContain(9)
|
||||||
|
expect(months).toContain(10)
|
||||||
|
expect(months).toContain(11)
|
||||||
|
expect(months).toContain(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('29th day skips February in non-leap years', () => {
|
||||||
|
jest.setSystemTime(new Date('2023-01-15T08:00:00.000Z'))
|
||||||
|
|
||||||
|
const config = createMonthlyConfig([29])
|
||||||
|
const times = getNextExecutionTimes(config, 12)
|
||||||
|
|
||||||
|
const februaryExecutions = times.filter(date => date.getMonth() === 1)
|
||||||
|
expect(februaryExecutions).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('29th day includes February in leap years', () => {
|
||||||
|
jest.setSystemTime(new Date('2024-01-15T08:00:00.000Z'))
|
||||||
|
|
||||||
|
const config = createMonthlyConfig([29])
|
||||||
|
const times = getNextExecutionTimes(config, 12)
|
||||||
|
|
||||||
|
const februaryExecutions = times.filter(date => date.getMonth() === 1)
|
||||||
|
expect(februaryExecutions).toHaveLength(1)
|
||||||
|
expect(februaryExecutions[0].getDate()).toBe(29)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('last day vs specific day distinction', () => {
|
||||||
|
test('31st selection is different from last day in short months', () => {
|
||||||
|
const config31 = createMonthlyConfig([31])
|
||||||
|
const configLast = createMonthlyConfig(['last'])
|
||||||
|
|
||||||
|
const times31 = getNextExecutionTimes(config31, 12)
|
||||||
|
const timesLast = getNextExecutionTimes(configLast, 12)
|
||||||
|
|
||||||
|
const months31 = times31.map(date => date.getMonth() + 1)
|
||||||
|
const monthsLast = timesLast.map(date => date.getMonth() + 1)
|
||||||
|
|
||||||
|
expect(months31).not.toContain(2)
|
||||||
|
expect(monthsLast).toContain(2)
|
||||||
|
|
||||||
|
expect(months31).not.toContain(4)
|
||||||
|
expect(monthsLast).toContain(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('31st and last day both work correctly in 31-day months', () => {
|
||||||
|
const config31 = createMonthlyConfig([31])
|
||||||
|
const configLast = createMonthlyConfig(['last'])
|
||||||
|
|
||||||
|
const times31 = getNextExecutionTimes(config31, 5)
|
||||||
|
const timesLast = getNextExecutionTimes(configLast, 5)
|
||||||
|
|
||||||
|
const march31 = times31.find(date => date.getMonth() === 2)
|
||||||
|
const marchLast = timesLast.find(date => date.getMonth() === 2)
|
||||||
|
|
||||||
|
expect(march31?.getDate()).toBe(31)
|
||||||
|
expect(marchLast?.getDate()).toBe(31)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('mixed selection with 31st and last behaves correctly', () => {
|
||||||
|
const config = createMonthlyConfig([31, 'last'])
|
||||||
|
const times = getNextExecutionTimes(config, 12)
|
||||||
|
|
||||||
|
const februaryExecutions = times.filter(date => date.getMonth() === 1)
|
||||||
|
expect(februaryExecutions).toHaveLength(1)
|
||||||
|
expect(februaryExecutions[0].getDate()).toBe(29)
|
||||||
|
|
||||||
|
const marchExecutions = times.filter(date => date.getMonth() === 2)
|
||||||
|
expect(marchExecutions).toHaveLength(1)
|
||||||
|
expect(marchExecutions[0].getDate()).toBe(31)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deduplicates overlapping selections in 31-day months', () => {
|
||||||
|
const config = createMonthlyConfig([31, 'last'])
|
||||||
|
const times = getNextExecutionTimes(config, 12)
|
||||||
|
|
||||||
|
const monthsWith31Days = [0, 2, 4, 6, 7, 9, 11]
|
||||||
|
|
||||||
|
monthsWith31Days.forEach((month) => {
|
||||||
|
const monthExecutions = times.filter(date => date.getMonth() === month)
|
||||||
|
expect(monthExecutions.length).toBeLessThanOrEqual(1)
|
||||||
|
|
||||||
|
if (monthExecutions.length === 1)
|
||||||
|
expect(monthExecutions[0].getDate()).toBe(31)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deduplicates overlapping selections in 30-day months', () => {
|
||||||
|
const config = createMonthlyConfig([30, 'last'])
|
||||||
|
const times = getNextExecutionTimes(config, 12)
|
||||||
|
|
||||||
|
const monthsWith30Days = [3, 5, 8, 10]
|
||||||
|
|
||||||
|
monthsWith30Days.forEach((month) => {
|
||||||
|
const monthExecutions = times.filter(date => date.getMonth() === month)
|
||||||
|
expect(monthExecutions.length).toBeLessThanOrEqual(1)
|
||||||
|
|
||||||
|
if (monthExecutions.length === 1)
|
||||||
|
expect(monthExecutions[0].getDate()).toBe(30)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles complex multi-day with last selection', () => {
|
||||||
|
const config = createMonthlyConfig([15, 30, 31, 'last'])
|
||||||
|
const times = getNextExecutionTimes(config, 20)
|
||||||
|
|
||||||
|
const marchExecutions = times.filter(date => date.getMonth() === 2).sort((a, b) => a.getDate() - b.getDate())
|
||||||
|
expect(marchExecutions).toHaveLength(3)
|
||||||
|
expect(marchExecutions.map(d => d.getDate())).toEqual([15, 30, 31])
|
||||||
|
|
||||||
|
const aprilExecutions = times.filter(date => date.getMonth() === 3).sort((a, b) => a.getDate() - b.getDate())
|
||||||
|
expect(aprilExecutions).toHaveLength(2)
|
||||||
|
expect(aprilExecutions.map(d => d.getDate())).toEqual([15, 30])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('current month offset calculation', () => {
|
||||||
|
test('skips current month when no valid days exist', () => {
|
||||||
|
jest.setSystemTime(new Date('2024-02-15T08:00:00.000Z'))
|
||||||
|
|
||||||
|
const config = createMonthlyConfig([31])
|
||||||
|
const times = getNextExecutionTimes(config, 3)
|
||||||
|
|
||||||
|
times.forEach((date) => {
|
||||||
|
expect(date.getMonth()).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('includes current month when valid days exist', () => {
|
||||||
|
jest.setSystemTime(new Date('2024-03-15T08:00:00.000Z'))
|
||||||
|
|
||||||
|
const config = createMonthlyConfig([31])
|
||||||
|
const times = getNextExecutionTimes(config, 3)
|
||||||
|
|
||||||
|
const currentMonthExecution = times.find(date => date.getMonth() === 2)
|
||||||
|
expect(currentMonthExecution).toBeDefined()
|
||||||
|
expect(currentMonthExecution?.getDate()).toBe(31)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sorting and deduplication', () => {
|
||||||
|
test('handles duplicate selections correctly', () => {
|
||||||
|
const config = createMonthlyConfig([15, 15, 15])
|
||||||
|
const times = getNextExecutionTimes(config, 5)
|
||||||
|
|
||||||
|
const marchExecutions = times.filter(date => date.getMonth() === 2)
|
||||||
|
expect(marchExecutions).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sorts multiple days within same month', () => {
|
||||||
|
jest.setSystemTime(new Date('2024-03-01T08:00:00.000Z'))
|
||||||
|
|
||||||
|
const config = createMonthlyConfig([31, 15, 1])
|
||||||
|
const times = getNextExecutionTimes(config, 5)
|
||||||
|
|
||||||
|
const marchExecutions = times.filter(date => date.getMonth() === 2).sort((a, b) => a.getDate() - b.getDate())
|
||||||
|
expect(marchExecutions.map(d => d.getDate())).toEqual([1, 15, 31])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -32,11 +32,11 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
|
|||||||
const times = getNextExecutionTimes(config, 5)
|
const times = getNextExecutionTimes(config, 5)
|
||||||
|
|
||||||
expect(times).toHaveLength(5)
|
expect(times).toHaveLength(5)
|
||||||
expect(times[0].getDate()).toBe(30)
|
expect(times[0].getDate()).toBe(15)
|
||||||
expect(times[0].getMonth()).toBe(0)
|
expect(times[0].getMonth()).toBe(0)
|
||||||
expect(times[1].getDate()).toBe(1)
|
expect(times[1].getDate()).toBe(30)
|
||||||
expect(times[1].getMonth()).toBe(1)
|
expect(times[1].getMonth()).toBe(0)
|
||||||
expect(times[2].getDate()).toBe(15)
|
expect(times[2].getDate()).toBe(1)
|
||||||
expect(times[2].getMonth()).toBe(1)
|
expect(times[2].getMonth()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -58,8 +58,12 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
|
|||||||
const times = getNextExecutionTimes(config, 6)
|
const times = getNextExecutionTimes(config, 6)
|
||||||
|
|
||||||
const febTimes = times.filter(t => t.getMonth() === 1)
|
const febTimes = times.filter(t => t.getMonth() === 1)
|
||||||
expect(febTimes.length).toBeGreaterThan(0)
|
expect(febTimes.length).toBe(0)
|
||||||
expect(febTimes[0].getDate()).toBe(29)
|
|
||||||
|
const marchTimes = times.filter(t => t.getMonth() === 2)
|
||||||
|
expect(marchTimes.length).toBe(2)
|
||||||
|
expect(marchTimes[0].getDate()).toBe(30)
|
||||||
|
expect(marchTimes[1].getDate()).toBe(31)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sorts execution times chronologically', () => {
|
test('sorts execution times chronologically', () => {
|
||||||
@ -182,8 +186,8 @@ describe('Monthly Multi-Select Execution Time Calculator', () => {
|
|||||||
|
|
||||||
expect(times[0].getDate()).toBe(29)
|
expect(times[0].getDate()).toBe(29)
|
||||||
expect(times[0].getMonth()).toBe(0)
|
expect(times[0].getMonth()).toBe(0)
|
||||||
expect(times[1].getDate()).toBe(28)
|
expect(times[1].getDate()).toBe(29)
|
||||||
expect(times[1].getMonth()).toBe(1)
|
expect(times[1].getMonth()).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ const FrequencySelector = ({ frequency, onChange }: FrequencySelectorProps) => {
|
|||||||
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
|
{ value: 'daily', name: t('workflow.nodes.triggerSchedule.frequency.daily') },
|
||||||
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
|
{ value: 'weekly', name: t('workflow.nodes.triggerSchedule.frequency.weekly') },
|
||||||
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
|
{ value: 'monthly', name: t('workflow.nodes.triggerSchedule.frequency.monthly') },
|
||||||
{ value: 'once', name: t('workflow.nodes.triggerSchedule.frequency.once') },
|
|
||||||
], [t])
|
], [t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -73,6 +73,15 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Warning message for day 31 - aligned with grid */}
|
||||||
|
{selectedDays?.includes(31) && (
|
||||||
|
<div className="mt-1.5 grid grid-cols-7 gap-1.5">
|
||||||
|
<div className="col-span-7 text-xs text-gray-500">
|
||||||
|
{t('workflow.nodes.triggerSchedule.lastDayTooltip')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,7 @@ type NextExecutionTimesProps = {
|
|||||||
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
|
const NextExecutionTimes = ({ data }: NextExecutionTimesProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Don't show next execution times for 'once' frequency in visual mode
|
if (!data.frequency)
|
||||||
if (data.mode === 'visual' && data.frequency === 'once')
|
|
||||||
return null
|
return null
|
||||||
|
|
||||||
const executionTimes = getFormattedExecutionTimes(data, 5)
|
const executionTimes = getFormattedExecutionTimes(data, 5)
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Slider from '@/app/components/base/slider'
|
||||||
|
|
||||||
|
type OnMinuteSelectorProps = {
|
||||||
|
value?: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||||
|
{t('workflow.nodes.triggerSchedule.onMinute')}
|
||||||
|
</label>
|
||||||
|
<div className="relative flex h-8 items-center rounded-lg bg-components-input-bg-normal">
|
||||||
|
<div className="flex h-full w-12 shrink-0 items-center justify-center text-[13px] text-components-input-text-filled">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-12 top-0 h-full w-px bg-components-panel-bg"></div>
|
||||||
|
<div className="flex h-full grow items-center pl-4 pr-3">
|
||||||
|
<Slider
|
||||||
|
className="w-full"
|
||||||
|
value={value}
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
step={1}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnMinuteSelector
|
||||||
@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -19,12 +19,16 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
|||||||
{ key: 'sat', label: 'Sat' },
|
{ key: 'sat', label: 'Sat' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedDay = selectedDays.length > 0 ? selectedDays[0] : 'sun'
|
|
||||||
|
|
||||||
const handleDaySelect = (dayKey: string) => {
|
const handleDaySelect = (dayKey: string) => {
|
||||||
onChange([dayKey])
|
const current = selectedDays || []
|
||||||
|
const newSelected = current.includes(dayKey)
|
||||||
|
? current.filter(d => d !== dayKey)
|
||||||
|
: [...current, dayKey]
|
||||||
|
onChange(newSelected.length > 0 ? newSelected : [dayKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||||
@ -36,7 +40,7 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
|||||||
key={day.key}
|
key={day.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex-1 rounded-lg border bg-components-option-card-option-bg py-1 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
|
isDaySelected(day.key)
|
||||||
? 'border-util-colors-blue-brand-blue-brand-600 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'
|
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@ -19,24 +19,8 @@ const isValidTimeFormat = (time: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validateHourlyConfig = (config: any, t: any): string => {
|
const validateHourlyConfig = (config: any, t: any): string => {
|
||||||
const i18nPrefix = 'workflow.errorMsg'
|
if (config.on_minute === undefined || config.on_minute < 0 || config.on_minute > 59)
|
||||||
|
return t('workflow.nodes.triggerSchedule.invalidOnMinute')
|
||||||
if (!config.datetime)
|
|
||||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.startTime') })
|
|
||||||
|
|
||||||
const startTime = new Date(config.datetime)
|
|
||||||
if (Number.isNaN(startTime.getTime()))
|
|
||||||
return t('workflow.nodes.triggerSchedule.invalidStartTime')
|
|
||||||
|
|
||||||
if (startTime <= new Date())
|
|
||||||
return t('workflow.nodes.triggerSchedule.startTimeMustBeFuture')
|
|
||||||
|
|
||||||
const recurEvery = config.recur_every || 1
|
|
||||||
if (recurEvery < 1 || recurEvery > 999)
|
|
||||||
return t('workflow.nodes.triggerSchedule.invalidRecurEvery')
|
|
||||||
|
|
||||||
if (!config.recur_unit || !['hours', 'minutes'].includes(config.recur_unit))
|
|
||||||
return t('workflow.nodes.triggerSchedule.invalidRecurUnit')
|
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@ -97,22 +81,6 @@ const validateMonthlyConfig = (config: any, t: any): string => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateOnceConfig = (config: any, t: any): string => {
|
|
||||||
const i18nPrefix = 'workflow.errorMsg'
|
|
||||||
|
|
||||||
if (!config.datetime)
|
|
||||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.executionTime') })
|
|
||||||
|
|
||||||
const executionTime = new Date(config.datetime)
|
|
||||||
if (Number.isNaN(executionTime.getTime()))
|
|
||||||
return t('workflow.nodes.triggerSchedule.invalidExecutionTime')
|
|
||||||
|
|
||||||
if (executionTime <= new Date())
|
|
||||||
return t('workflow.nodes.triggerSchedule.executionTimeMustBeFuture')
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => {
|
const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string => {
|
||||||
const i18nPrefix = 'workflow.errorMsg'
|
const i18nPrefix = 'workflow.errorMsg'
|
||||||
const { visual_config } = payload
|
const { visual_config } = payload
|
||||||
@ -129,8 +97,6 @@ const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string
|
|||||||
return validateWeeklyConfig(visual_config, t)
|
return validateWeeklyConfig(visual_config, t)
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
return validateMonthlyConfig(visual_config, t)
|
return validateMonthlyConfig(visual_config, t)
|
||||||
case 'once':
|
|
||||||
return validateOnceConfig(visual_config, t)
|
|
||||||
default:
|
default:
|
||||||
return t('workflow.nodes.triggerSchedule.invalidFrequency')
|
return t('workflow.nodes.triggerSchedule.invalidFrequency')
|
||||||
}
|
}
|
||||||
@ -139,7 +105,7 @@ const validateVisualConfig = (payload: ScheduleTriggerNodeType, t: any): string
|
|||||||
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
const nodeDefault: NodeDefault<ScheduleTriggerNodeType> = {
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
mode: 'visual',
|
mode: 'visual',
|
||||||
frequency: 'daily',
|
frequency: 'weekly',
|
||||||
cron_expression: '',
|
cron_expression: '',
|
||||||
visual_config: {
|
visual_config: {
|
||||||
time: '11:30 AM',
|
time: '11:30 AM',
|
||||||
|
|||||||
@ -8,12 +8,11 @@ import ModeToggle from './components/mode-toggle'
|
|||||||
import FrequencySelector from './components/frequency-selector'
|
import FrequencySelector from './components/frequency-selector'
|
||||||
import WeekdaySelector from './components/weekday-selector'
|
import WeekdaySelector from './components/weekday-selector'
|
||||||
import TimePicker from '@/app/components/base/date-and-time-picker/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 dayjs from 'dayjs'
|
||||||
import NextExecutionTimes from './components/next-execution-times'
|
import NextExecutionTimes from './components/next-execution-times'
|
||||||
import ExecuteNowButton from './components/execute-now-button'
|
import ExecuteNowButton from './components/execute-now-button'
|
||||||
import RecurConfig from './components/recur-config'
|
|
||||||
import MonthlyDaysSelector from './components/monthly-days-selector'
|
import MonthlyDaysSelector from './components/monthly-days-selector'
|
||||||
|
import OnMinuteSelector from './components/on-minute-selector'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import useConfig from './use-config'
|
import useConfig from './use-config'
|
||||||
|
|
||||||
@ -32,8 +31,7 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
|||||||
handleCronExpressionChange,
|
handleCronExpressionChange,
|
||||||
handleWeekdaysChange,
|
handleWeekdaysChange,
|
||||||
handleTimeChange,
|
handleTimeChange,
|
||||||
handleRecurEveryChange,
|
handleOnMinuteChange,
|
||||||
handleRecurUnitChange,
|
|
||||||
} = useConfig(id, data)
|
} = useConfig(id, data)
|
||||||
|
|
||||||
const handleExecuteNow = () => {
|
const handleExecuteNow = () => {
|
||||||
@ -68,57 +66,34 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="mb-2 block text-xs font-medium text-gray-500">
|
{inputs.frequency === 'hourly' ? (
|
||||||
{inputs.frequency === 'hourly' || inputs.frequency === 'once'
|
<OnMinuteSelector
|
||||||
? t('workflow.nodes.triggerSchedule.startTime')
|
value={inputs.visual_config?.on_minute}
|
||||||
: t('workflow.nodes.triggerSchedule.time')
|
onChange={handleOnMinuteChange}
|
||||||
}
|
|
||||||
</label>
|
|
||||||
{inputs.frequency === 'hourly' || inputs.frequency === 'once' ? (
|
|
||||||
<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: 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
|
<>
|
||||||
notClearable={true}
|
<label className="mb-2 block text-xs font-medium text-gray-500">
|
||||||
value={inputs.visual_config?.time
|
{t('workflow.nodes.triggerSchedule.time')}
|
||||||
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
|
</label>
|
||||||
: dayjs('1/1/2000 11:30 AM')
|
<TimePicker
|
||||||
}
|
notClearable={true}
|
||||||
onChange={(time) => {
|
value={inputs.visual_config?.time
|
||||||
if (time) {
|
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
|
||||||
const timeString = time.format('h:mm A')
|
: dayjs('1/1/2000 11:30 AM')
|
||||||
handleTimeChange(timeString)
|
|
||||||
}
|
}
|
||||||
}}
|
onChange={(time) => {
|
||||||
onClear={() => {
|
if (time) {
|
||||||
handleTimeChange('11:30 AM')
|
const timeString = time.format('h:mm A')
|
||||||
}}
|
handleTimeChange(timeString)
|
||||||
placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
|
}
|
||||||
/>
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
handleTimeChange('11:30 AM')
|
||||||
|
}}
|
||||||
|
placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -130,15 +105,6 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inputs.frequency === 'hourly' && (
|
|
||||||
<RecurConfig
|
|
||||||
recurEvery={inputs.visual_config?.recur_every}
|
|
||||||
recurUnit={inputs.visual_config?.recur_unit}
|
|
||||||
onRecurEveryChange={handleRecurEveryChange}
|
|
||||||
onRecurUnitChange={handleRecurUnitChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{inputs.frequency === 'monthly' && (
|
{inputs.frequency === 'monthly' && (
|
||||||
<MonthlyDaysSelector
|
<MonthlyDaysSelector
|
||||||
selectedDays={inputs.visual_config?.monthly_days || [1]}
|
selectedDays={inputs.visual_config?.monthly_days || [1]}
|
||||||
@ -170,9 +136,6 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
|||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Enter cron expression (minute hour day month weekday)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,15 +2,12 @@ import type { CommonNodeType } from '@/app/components/workflow/types'
|
|||||||
|
|
||||||
export type ScheduleMode = 'visual' | 'cron'
|
export type ScheduleMode = 'visual' | 'cron'
|
||||||
|
|
||||||
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'once'
|
export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||||
|
|
||||||
export type VisualConfig = {
|
export type VisualConfig = {
|
||||||
time?: string
|
time?: string
|
||||||
datetime?: string
|
|
||||||
days?: number[]
|
|
||||||
weekdays?: string[]
|
weekdays?: string[]
|
||||||
recur_every?: number
|
on_minute?: number
|
||||||
recur_unit?: 'hours' | 'minutes'
|
|
||||||
monthly_days?: (number | 'last')[]
|
monthly_days?: (number | 'last')[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,25 +1,64 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
|
import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types'
|
||||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||||
|
import { convertTimeToUTC, convertUTCToUserTimezone, isUTCFormat, isUserFormat } from './utils/timezone-utils'
|
||||||
|
|
||||||
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
||||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||||
|
|
||||||
const defaultPayload = {
|
const frontendPayload = useMemo(() => {
|
||||||
...payload,
|
const basePayload = {
|
||||||
mode: payload.mode || 'visual',
|
...payload,
|
||||||
frequency: payload.frequency || 'daily',
|
mode: payload.mode || 'visual',
|
||||||
visual_config: {
|
frequency: payload.frequency || 'weekly',
|
||||||
time: '11:30 AM',
|
timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
weekdays: ['sun'],
|
enabled: payload.enabled !== undefined ? payload.enabled : true,
|
||||||
...payload.visual_config,
|
}
|
||||||
},
|
|
||||||
timezone: payload.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
enabled: payload.enabled !== undefined ? payload.enabled : true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, defaultPayload)
|
// 只有当时间是UTC格式时才需要转换为用户时区格式显示
|
||||||
|
const needsConversion = payload.visual_config?.time
|
||||||
|
&& payload.timezone
|
||||||
|
&& isUTCFormat(payload.visual_config.time)
|
||||||
|
|
||||||
|
if (needsConversion) {
|
||||||
|
const userTime = convertUTCToUserTimezone(payload.visual_config.time, payload.timezone)
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
visual_config: {
|
||||||
|
...payload.visual_config,
|
||||||
|
time: userTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认值或已经是用户格式,直接使用
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
visual_config: {
|
||||||
|
time: '11:30 AM',
|
||||||
|
weekdays: ['sun'],
|
||||||
|
...payload.visual_config,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [payload])
|
||||||
|
|
||||||
|
const { inputs, setInputs } = useNodeCrud<ScheduleTriggerNodeType>(id, frontendPayload, {
|
||||||
|
beforeSave: (data) => {
|
||||||
|
// 只转换用户时间格式为UTC,避免重复转换
|
||||||
|
if (data.visual_config?.time && data.timezone && isUserFormat(data.visual_config.time)) {
|
||||||
|
const utcTime = convertTimeToUTC(data.visual_config.time, data.timezone)
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
visual_config: {
|
||||||
|
...data.visual_config,
|
||||||
|
time: utcTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleModeChange = useCallback((mode: ScheduleMode) => {
|
const handleModeChange = useCallback((mode: ScheduleMode) => {
|
||||||
const newInputs = {
|
const newInputs = {
|
||||||
@ -35,15 +74,8 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
|||||||
frequency,
|
frequency,
|
||||||
visual_config: {
|
visual_config: {
|
||||||
...inputs.visual_config,
|
...inputs.visual_config,
|
||||||
...(frequency === 'hourly' || frequency === 'once') && !inputs.visual_config?.datetime && {
|
|
||||||
datetime: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
...(frequency === 'hourly') && {
|
...(frequency === 'hourly') && {
|
||||||
recur_every: inputs.visual_config?.recur_every || 1,
|
on_minute: inputs.visual_config?.on_minute ?? 0,
|
||||||
recur_unit: inputs.visual_config?.recur_unit || 'hours',
|
|
||||||
},
|
|
||||||
...(frequency !== 'hourly' && frequency !== 'once') && {
|
|
||||||
datetime: undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -80,23 +112,12 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
|||||||
setInputs(newInputs)
|
setInputs(newInputs)
|
||||||
}, [inputs, setInputs])
|
}, [inputs, setInputs])
|
||||||
|
|
||||||
const handleRecurEveryChange = useCallback((recur_every: number) => {
|
const handleOnMinuteChange = useCallback((on_minute: number) => {
|
||||||
const newInputs = {
|
const newInputs = {
|
||||||
...inputs,
|
...inputs,
|
||||||
visual_config: {
|
visual_config: {
|
||||||
...inputs.visual_config,
|
...inputs.visual_config,
|
||||||
recur_every,
|
on_minute,
|
||||||
},
|
|
||||||
}
|
|
||||||
setInputs(newInputs)
|
|
||||||
}, [inputs, setInputs])
|
|
||||||
|
|
||||||
const handleRecurUnitChange = useCallback((recur_unit: 'hours' | 'minutes') => {
|
|
||||||
const newInputs = {
|
|
||||||
...inputs,
|
|
||||||
visual_config: {
|
|
||||||
...inputs.visual_config,
|
|
||||||
recur_unit,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
setInputs(newInputs)
|
setInputs(newInputs)
|
||||||
@ -111,8 +132,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
|
|||||||
handleCronExpressionChange,
|
handleCronExpressionChange,
|
||||||
handleWeekdaysChange,
|
handleWeekdaysChange,
|
||||||
handleTimeChange,
|
handleTimeChange,
|
||||||
handleRecurEveryChange,
|
handleOnMinuteChange,
|
||||||
handleRecurUnitChange,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,12 @@
|
|||||||
import type { ScheduleTriggerNodeType } from '../types'
|
import type { ScheduleTriggerNodeType } from '../types'
|
||||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||||
|
import { formatDateInTimezone, getCurrentTimeInTimezone } from './timezone-utils'
|
||||||
|
|
||||||
// Helper function to get current time - timezone is handled by Date object natively
|
const getCurrentTime = (timezone?: string): Date => {
|
||||||
const getCurrentTime = (): Date => {
|
return timezone ? getCurrentTimeInTimezone(timezone) : new Date()
|
||||||
return new Date()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get default datetime for once/hourly modes - consistent with base DatePicker
|
// Helper function to get default datetime - consistent with base DatePicker
|
||||||
export const getDefaultDateTime = (): Date => {
|
export const getDefaultDateTime = (): Date => {
|
||||||
const defaultDate = new Date()
|
const defaultDate = new Date()
|
||||||
defaultDate.setHours(11, 30, 0, 0)
|
defaultDate.setHours(11, 30, 0, 0)
|
||||||
@ -25,20 +25,21 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
const defaultTime = data.visual_config?.time || '11:30 AM'
|
const defaultTime = data.visual_config?.time || '11:30 AM'
|
||||||
|
|
||||||
if (data.frequency === 'hourly') {
|
if (data.frequency === 'hourly') {
|
||||||
if (!data.visual_config?.datetime)
|
const onMinute = data.visual_config?.on_minute ?? 0
|
||||||
return []
|
const now = getCurrentTime(data.timezone)
|
||||||
|
const currentHour = now.getHours()
|
||||||
|
const currentMinute = now.getMinutes()
|
||||||
|
|
||||||
const baseTime = new Date(data.visual_config.datetime)
|
let nextExecution: Date
|
||||||
const recurUnit = data.visual_config?.recur_unit || 'hours'
|
if (currentMinute <= onMinute)
|
||||||
const recurEvery = data.visual_config?.recur_every || 1
|
nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), currentHour, onMinute, 0, 0)
|
||||||
|
else
|
||||||
const intervalMs = recurUnit === 'hours'
|
nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), currentHour + 1, onMinute, 0, 0)
|
||||||
? recurEvery * 60 * 60 * 1000
|
|
||||||
: recurEvery * 60 * 1000
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const executionTime = new Date(baseTime.getTime() + i * intervalMs)
|
const execution = new Date(nextExecution)
|
||||||
times.push(executionTime)
|
execution.setHours(nextExecution.getHours() + i)
|
||||||
|
times.push(execution)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (data.frequency === 'daily') {
|
else if (data.frequency === 'daily') {
|
||||||
@ -48,7 +49,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||||
|
|
||||||
const now = getCurrentTime()
|
const now = getCurrentTime(data.timezone)
|
||||||
const baseExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
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
|
// Calculate initial offset: if time has passed today, start from tomorrow
|
||||||
@ -61,9 +62,8 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (data.frequency === 'weekly') {
|
else if (data.frequency === 'weekly') {
|
||||||
const selectedDay = data.visual_config?.weekdays?.[0] || 'sun'
|
const selectedDays = data.visual_config?.weekdays || ['sun']
|
||||||
const dayMap = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
|
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 [time, period] = defaultTime.split(' ')
|
||||||
const [hour, minute] = time.split(':')
|
const [hour, minute] = time.split(':')
|
||||||
@ -71,20 +71,46 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||||
|
|
||||||
const now = getCurrentTime()
|
const now = getCurrentTime(data.timezone)
|
||||||
const currentDay = now.getDay()
|
let weekOffset = 0
|
||||||
let daysUntilNext = (targetDay - currentDay + 7) % 7
|
|
||||||
|
|
||||||
const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
const currentWeekExecutions: Date[] = []
|
||||||
|
for (const selectedDay of selectedDays) {
|
||||||
|
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
||||||
|
let daysUntilNext = (targetDay - now.getDay() + 7) % 7
|
||||||
|
|
||||||
if (daysUntilNext === 0 && nextExecutionBase <= now)
|
const nextExecutionBase = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
||||||
daysUntilNext = 7
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
if (daysUntilNext === 0 && nextExecutionBase <= now)
|
||||||
const nextExecution = new Date(nextExecutionBase)
|
daysUntilNext = 7
|
||||||
nextExecution.setDate(nextExecution.getDate() + daysUntilNext + (i * 7))
|
|
||||||
times.push(nextExecution)
|
if (daysUntilNext < 7) {
|
||||||
|
const execution = new Date(nextExecutionBase)
|
||||||
|
execution.setDate(execution.getDate() + daysUntilNext)
|
||||||
|
currentWeekExecutions.push(execution)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentWeekExecutions.length === 0)
|
||||||
|
weekOffset = 1
|
||||||
|
|
||||||
|
let weeksChecked = 0
|
||||||
|
while (times.length < count && weeksChecked < 8) {
|
||||||
|
for (const selectedDay of selectedDays) {
|
||||||
|
if (times.length >= count) break
|
||||||
|
|
||||||
|
const targetDay = dayMap[selectedDay as keyof typeof dayMap]
|
||||||
|
const execution = new Date(now.getFullYear(), now.getMonth(), now.getDate(), displayHour, Number.parseInt(minute), 0, 0)
|
||||||
|
execution.setDate(execution.getDate() + (targetDay - now.getDay() + 7) % 7 + (weekOffset + weeksChecked) * 7)
|
||||||
|
|
||||||
|
if (execution > now)
|
||||||
|
times.push(execution)
|
||||||
|
}
|
||||||
|
weeksChecked++
|
||||||
|
}
|
||||||
|
|
||||||
|
times.sort((a, b) => a.getTime() - b.getTime())
|
||||||
|
times.splice(count)
|
||||||
}
|
}
|
||||||
else if (data.frequency === 'monthly') {
|
else if (data.frequency === 'monthly') {
|
||||||
const getSelectedDays = (): (number | 'last')[] => {
|
const getSelectedDays = (): (number | 'last')[] => {
|
||||||
@ -101,7 +127,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
if (period === 'PM' && displayHour !== 12) displayHour += 12
|
||||||
if (period === 'AM' && displayHour === 12) displayHour = 0
|
if (period === 'AM' && displayHour === 12) displayHour = 0
|
||||||
|
|
||||||
const now = getCurrentTime()
|
const now = getCurrentTime(data.timezone)
|
||||||
let monthOffset = 0
|
let monthOffset = 0
|
||||||
|
|
||||||
const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => {
|
const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => {
|
||||||
@ -109,10 +135,16 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
|
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
|
||||||
|
|
||||||
let targetDay: number
|
let targetDay: number
|
||||||
if (selectedDay === 'last')
|
if (selectedDay === 'last') {
|
||||||
targetDay = daysInMonth
|
targetDay = daysInMonth
|
||||||
else
|
}
|
||||||
targetDay = Math.min(selectedDay as number, daysInMonth)
|
else {
|
||||||
|
const dayNumber = selectedDay as number
|
||||||
|
if (dayNumber > daysInMonth)
|
||||||
|
return false
|
||||||
|
|
||||||
|
targetDay = dayNumber
|
||||||
|
}
|
||||||
|
|
||||||
const execution = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
const execution = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||||
return execution > now
|
return execution > now
|
||||||
@ -128,14 +160,26 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||||
|
|
||||||
const monthlyExecutions: Date[] = []
|
const monthlyExecutions: Date[] = []
|
||||||
|
const processedDays = new Set<number>()
|
||||||
|
|
||||||
for (const selectedDay of selectedDays) {
|
for (const selectedDay of selectedDays) {
|
||||||
let targetDay: number
|
let targetDay: number
|
||||||
|
|
||||||
if (selectedDay === 'last')
|
if (selectedDay === 'last') {
|
||||||
targetDay = daysInMonth
|
targetDay = daysInMonth
|
||||||
else
|
}
|
||||||
targetDay = Math.min(selectedDay as number, daysInMonth)
|
else {
|
||||||
|
const dayNumber = selectedDay as number
|
||||||
|
if (dayNumber > daysInMonth)
|
||||||
|
continue
|
||||||
|
|
||||||
|
targetDay = dayNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedDays.has(targetDay))
|
||||||
|
continue
|
||||||
|
|
||||||
|
processedDays.add(targetDay)
|
||||||
|
|
||||||
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||||
|
|
||||||
@ -153,16 +197,10 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
monthsChecked++
|
monthsChecked++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
else {
|
||||||
// Fallback for unknown frequencies
|
// Fallback for unknown frequencies
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const now = getCurrentTime()
|
const now = getCurrentTime(data.timezone)
|
||||||
const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1)
|
const nextExecution = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1)
|
||||||
times.push(nextExecution)
|
times.push(nextExecution)
|
||||||
}
|
}
|
||||||
@ -171,46 +209,25 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||||||
return times
|
return times
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatExecutionTime = (date: Date, includeWeekday: boolean = true): string => {
|
export const formatExecutionTime = (date: Date, timezone: string, includeWeekday: boolean = true): string => {
|
||||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
return formatDateInTimezone(date, timezone, includeWeekday)
|
||||||
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[] => {
|
export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count: number = 5): string[] => {
|
||||||
const times = getNextExecutionTimes(data, count)
|
const times = getNextExecutionTimes(data, count)
|
||||||
|
|
||||||
return times.map((date) => {
|
return times.map((date) => {
|
||||||
// Only weekly frequency includes weekday in format
|
|
||||||
const includeWeekday = data.frequency === 'weekly'
|
const includeWeekday = data.frequency === 'weekly'
|
||||||
return formatExecutionTime(date, includeWeekday)
|
return formatExecutionTime(date, data.timezone, includeWeekday)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
|
||||||
const times = getFormattedExecutionTimes(data, 1)
|
const times = getFormattedExecutionTimes(data, 1)
|
||||||
if (times.length === 0) {
|
if (times.length === 0) {
|
||||||
if (data.frequency === 'once') {
|
const now = getCurrentTime(data.timezone)
|
||||||
const defaultDate = getDefaultDateTime()
|
|
||||||
return formatExecutionTime(defaultDate, false)
|
|
||||||
}
|
|
||||||
const now = getCurrentTime()
|
|
||||||
const includeWeekday = data.frequency === 'weekly'
|
const includeWeekday = data.frequency === 'weekly'
|
||||||
return formatExecutionTime(now, includeWeekday)
|
return formatExecutionTime(now, data.timezone, includeWeekday)
|
||||||
}
|
}
|
||||||
return times[0]
|
return times[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,281 @@
|
|||||||
|
import {
|
||||||
|
convertTimeToUTC,
|
||||||
|
convertUTCToUserTimezone,
|
||||||
|
formatDateInTimezone,
|
||||||
|
getCurrentTimeInTimezone,
|
||||||
|
isUTCFormat,
|
||||||
|
isUserFormat,
|
||||||
|
} from './timezone-utils'
|
||||||
|
|
||||||
|
describe('timezone-utils', () => {
|
||||||
|
describe('convertTimeToUTC', () => {
|
||||||
|
test('converts Eastern time to UTC correctly', () => {
|
||||||
|
const easternTime = '2:30 PM'
|
||||||
|
const timezone = 'America/New_York'
|
||||||
|
|
||||||
|
const result = convertTimeToUTC(easternTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toMatch(/^([01]?\d|2[0-3]):[0-5]\d$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts UTC time to UTC correctly', () => {
|
||||||
|
const utcTime = '2:30 PM'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertTimeToUTC(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe('14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles midnight correctly', () => {
|
||||||
|
const midnightTime = '12:00 AM'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertTimeToUTC(midnightTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe('00:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles noon correctly', () => {
|
||||||
|
const noonTime = '12:00 PM'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertTimeToUTC(noonTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe('12:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles Pacific time to UTC', () => {
|
||||||
|
const pacificTime = '9:15 AM'
|
||||||
|
const timezone = 'America/Los_Angeles'
|
||||||
|
|
||||||
|
const result = convertTimeToUTC(pacificTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toMatch(/^([01]?\d|2[0-3]):[0-5]\d$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles malformed time gracefully', () => {
|
||||||
|
const invalidTime = 'invalid time'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertTimeToUTC(invalidTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe(invalidTime)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('convertUTCToUserTimezone', () => {
|
||||||
|
test('converts UTC to Eastern time correctly', () => {
|
||||||
|
const utcTime = '19:30'
|
||||||
|
const timezone = 'America/New_York'
|
||||||
|
|
||||||
|
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toMatch(/^([1-9]|1[0-2]):[0-5]\d (AM|PM)$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts UTC to UTC correctly', () => {
|
||||||
|
const utcTime = '14:30'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe('2:30 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles midnight UTC correctly', () => {
|
||||||
|
const utcTime = '00:00'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe('12:00 AM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles noon UTC correctly', () => {
|
||||||
|
const utcTime = '12:00'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe('12:00 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles UTC to Pacific time', () => {
|
||||||
|
const utcTime = '17:15'
|
||||||
|
const timezone = 'America/Los_Angeles'
|
||||||
|
|
||||||
|
const result = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toMatch(/^([1-9]|1[0-2]):[0-5]\d (AM|PM)$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles malformed UTC time gracefully', () => {
|
||||||
|
const invalidTime = 'invalid'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const result = convertUTCToUserTimezone(invalidTime, timezone)
|
||||||
|
|
||||||
|
expect(result).toBe(invalidTime)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('timezone conversion round trip', () => {
|
||||||
|
test('UTC round trip conversion', () => {
|
||||||
|
const originalTime = '2:30 PM'
|
||||||
|
const timezone = 'UTC'
|
||||||
|
|
||||||
|
const utcTime = convertTimeToUTC(originalTime, timezone)
|
||||||
|
const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(backToUserTime).toBe(originalTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('different timezones produce valid results', () => {
|
||||||
|
const originalTime = '9:00 AM'
|
||||||
|
const timezones = ['America/New_York', 'America/Los_Angeles', 'Europe/London', 'Asia/Tokyo']
|
||||||
|
|
||||||
|
timezones.forEach((timezone) => {
|
||||||
|
const utcTime = convertTimeToUTC(originalTime, timezone)
|
||||||
|
const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(utcTime).toMatch(/^\d{2}:\d{2}$/)
|
||||||
|
expect(backToUserTime).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('edge cases produce valid formats', () => {
|
||||||
|
const edgeCases = ['12:00 AM', '12:00 PM', '11:59 PM', '12:01 AM']
|
||||||
|
const timezone = 'America/New_York'
|
||||||
|
|
||||||
|
edgeCases.forEach((time) => {
|
||||||
|
const utcTime = convertTimeToUTC(time, timezone)
|
||||||
|
const backToUserTime = convertUTCToUserTimezone(utcTime, timezone)
|
||||||
|
|
||||||
|
expect(utcTime).toMatch(/^\d{2}:\d{2}$/)
|
||||||
|
expect(backToUserTime).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isUTCFormat', () => {
|
||||||
|
test('identifies valid UTC format', () => {
|
||||||
|
expect(isUTCFormat('14:30')).toBe(true)
|
||||||
|
expect(isUTCFormat('00:00')).toBe(true)
|
||||||
|
expect(isUTCFormat('23:59')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects invalid UTC format', () => {
|
||||||
|
expect(isUTCFormat('2:30 PM')).toBe(false)
|
||||||
|
expect(isUTCFormat('14:3')).toBe(false)
|
||||||
|
expect(isUTCFormat('25:00')).toBe(true)
|
||||||
|
expect(isUTCFormat('invalid')).toBe(false)
|
||||||
|
expect(isUTCFormat('')).toBe(false)
|
||||||
|
expect(isUTCFormat('1:30')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isUserFormat', () => {
|
||||||
|
test('identifies valid user format', () => {
|
||||||
|
expect(isUserFormat('2:30 PM')).toBe(true)
|
||||||
|
expect(isUserFormat('12:00 AM')).toBe(true)
|
||||||
|
expect(isUserFormat('11:59 PM')).toBe(true)
|
||||||
|
expect(isUserFormat('1:00 AM')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects invalid user format', () => {
|
||||||
|
expect(isUserFormat('14:30')).toBe(false)
|
||||||
|
expect(isUserFormat('2:30')).toBe(false)
|
||||||
|
expect(isUserFormat('2:30 XM')).toBe(false)
|
||||||
|
expect(isUserFormat('25:00 PM')).toBe(true)
|
||||||
|
expect(isUserFormat('invalid')).toBe(false)
|
||||||
|
expect(isUserFormat('')).toBe(false)
|
||||||
|
expect(isUserFormat('2:3 PM')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCurrentTimeInTimezone', () => {
|
||||||
|
test('returns current time in specified timezone', () => {
|
||||||
|
const utcTime = getCurrentTimeInTimezone('UTC')
|
||||||
|
const easternTime = getCurrentTimeInTimezone('America/New_York')
|
||||||
|
const pacificTime = getCurrentTimeInTimezone('America/Los_Angeles')
|
||||||
|
|
||||||
|
expect(utcTime).toBeInstanceOf(Date)
|
||||||
|
expect(easternTime).toBeInstanceOf(Date)
|
||||||
|
expect(pacificTime).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles invalid timezone gracefully', () => {
|
||||||
|
const result = getCurrentTimeInTimezone('Invalid/Timezone')
|
||||||
|
expect(result).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('timezone differences are reasonable', () => {
|
||||||
|
const utcTime = getCurrentTimeInTimezone('UTC')
|
||||||
|
const easternTime = getCurrentTimeInTimezone('America/New_York')
|
||||||
|
|
||||||
|
const timeDiff = Math.abs(utcTime.getTime() - easternTime.getTime())
|
||||||
|
expect(timeDiff).toBeLessThan(24 * 60 * 60 * 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDateInTimezone', () => {
|
||||||
|
const testDate = new Date('2024-03-15T14:30:00.000Z')
|
||||||
|
|
||||||
|
test('formats date with weekday by default', () => {
|
||||||
|
const result = formatDateInTimezone(testDate, 'UTC')
|
||||||
|
|
||||||
|
expect(result).toContain('March')
|
||||||
|
expect(result).toContain('15')
|
||||||
|
expect(result).toContain('2024')
|
||||||
|
expect(result).toContain('2:30 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats date without weekday when specified', () => {
|
||||||
|
const result = formatDateInTimezone(testDate, 'UTC', false)
|
||||||
|
|
||||||
|
expect(result).toContain('March')
|
||||||
|
expect(result).toContain('15')
|
||||||
|
expect(result).toContain('2024')
|
||||||
|
expect(result).toContain('2:30 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats date in different timezones', () => {
|
||||||
|
const utcResult = formatDateInTimezone(testDate, 'UTC')
|
||||||
|
const easternResult = formatDateInTimezone(testDate, 'America/New_York')
|
||||||
|
|
||||||
|
expect(utcResult).toContain('2:30 PM')
|
||||||
|
expect(easternResult).toMatch(/\d{1,2}:\d{2} (AM|PM)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles invalid timezone gracefully', () => {
|
||||||
|
const result = formatDateInTimezone(testDate, 'Invalid/Timezone')
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
expect(result.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling and edge cases', () => {
|
||||||
|
test('convertTimeToUTC handles empty strings', () => {
|
||||||
|
expect(convertTimeToUTC('', 'UTC')).toBe('')
|
||||||
|
expect(convertTimeToUTC('2:30 PM', '')).toBe('2:30 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('convertUTCToUserTimezone handles empty strings', () => {
|
||||||
|
expect(convertUTCToUserTimezone('', 'UTC')).toBe('')
|
||||||
|
expect(convertUTCToUserTimezone('14:30', '')).toBe('14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('convertTimeToUTC handles malformed input parts', () => {
|
||||||
|
expect(convertTimeToUTC('2:PM', 'UTC')).toBe('2:PM')
|
||||||
|
expect(convertTimeToUTC('2:30', 'UTC')).toBe('2:30')
|
||||||
|
expect(convertTimeToUTC('ABC:30 PM', 'UTC')).toBe('ABC:30 PM')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('convertUTCToUserTimezone handles malformed UTC input', () => {
|
||||||
|
expect(convertUTCToUserTimezone('AB:30', 'UTC')).toBe('AB:30')
|
||||||
|
expect(convertUTCToUserTimezone('14:', 'UTC')).toBe('14:')
|
||||||
|
expect(convertUTCToUserTimezone('14:XX', 'UTC')).toBe('14:XX')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
export const convertTimeToUTC = (time: string, userTimezone: string): string => {
|
||||||
|
try {
|
||||||
|
const [timePart, period] = time.split(' ')
|
||||||
|
if (!timePart || !period) return time
|
||||||
|
|
||||||
|
const [hour, minute] = timePart.split(':')
|
||||||
|
if (!hour || !minute) return time
|
||||||
|
|
||||||
|
let hour24 = Number.parseInt(hour, 10)
|
||||||
|
const minuteNum = Number.parseInt(minute, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(hour24) || Number.isNaN(minuteNum)) return time
|
||||||
|
|
||||||
|
if (period === 'PM' && hour24 !== 12) hour24 += 12
|
||||||
|
if (period === 'AM' && hour24 === 12) hour24 = 0
|
||||||
|
|
||||||
|
if (userTimezone === 'UTC')
|
||||||
|
return `${String(hour24).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = today.getMonth()
|
||||||
|
const day = today.getDate()
|
||||||
|
|
||||||
|
const userTime = new Date(year, month, day, hour24, minuteNum)
|
||||||
|
|
||||||
|
const tempFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: userTimezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userTimeInTz = tempFormatter.format(userTime).replace(', ', 'T')
|
||||||
|
const userTimeDate = new Date(userTimeInTz)
|
||||||
|
const offset = userTime.getTime() - userTimeDate.getTime()
|
||||||
|
const utcTime = new Date(userTime.getTime() + offset)
|
||||||
|
|
||||||
|
return `${String(utcTime.getHours()).padStart(2, '0')}:${String(utcTime.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertUTCToUserTimezone = (utcTime: string, userTimezone: string): string => {
|
||||||
|
try {
|
||||||
|
const [hour, minute] = utcTime.split(':')
|
||||||
|
if (!hour || !minute) return utcTime
|
||||||
|
|
||||||
|
const hourNum = Number.parseInt(hour, 10)
|
||||||
|
const minuteNum = Number.parseInt(minute, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(hourNum) || Number.isNaN(minuteNum)) return utcTime
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const dateStr = today.toISOString().split('T')[0]
|
||||||
|
const utcDate = new Date(`${dateStr}T${String(hourNum).padStart(2, '0')}:${String(minuteNum).padStart(2, '0')}:00.000Z`)
|
||||||
|
|
||||||
|
return utcDate.toLocaleTimeString('en-US', {
|
||||||
|
timeZone: userTimezone,
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return utcTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUTCFormat = (time: string): boolean => {
|
||||||
|
return /^\d{2}:\d{2}$/.test(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUserFormat = (time: string): boolean => {
|
||||||
|
return /^\d{1,2}:\d{2} (AM|PM)$/.test(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimezoneOffset = (timezone: string): number => {
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const utc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||||
|
const target = new Date(now.toLocaleString('en-US', { timeZone: timezone }))
|
||||||
|
return (target.getTime() - utc.getTime()) / (1000 * 60)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCurrentTimeInTimezone = (timezone: string): Date => {
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000)
|
||||||
|
const targetTime = new Date(utcTime + (getTimezoneOffset(timezone) * 60000))
|
||||||
|
return targetTime
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateInTimezone = (date: Date, timezone: string, includeWeekday: boolean = true): string => {
|
||||||
|
try {
|
||||||
|
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: timezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeWeekday)
|
||||||
|
dateOptions.weekday = 'short'
|
||||||
|
|
||||||
|
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
timeZone: timezone,
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${date.toLocaleDateString('en-US', dateOptions)} ${date.toLocaleTimeString('en-US', timeOptions)}`
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -938,7 +938,6 @@ const translation = {
|
|||||||
daily: 'Daily',
|
daily: 'Daily',
|
||||||
weekly: 'Weekly',
|
weekly: 'Weekly',
|
||||||
monthly: 'Monthly',
|
monthly: 'Monthly',
|
||||||
once: 'One time',
|
|
||||||
},
|
},
|
||||||
selectFrequency: 'Select frequency',
|
selectFrequency: 'Select frequency',
|
||||||
frequencyLabel: 'Frequency',
|
frequencyLabel: 'Frequency',
|
||||||
@ -951,9 +950,9 @@ const translation = {
|
|||||||
startTime: 'Start Time',
|
startTime: 'Start Time',
|
||||||
executeNow: 'Execution now',
|
executeNow: 'Execution now',
|
||||||
selectDateTime: 'Select Date & Time',
|
selectDateTime: 'Select Date & Time',
|
||||||
recurEvery: 'Recur every',
|
|
||||||
hours: 'Hours',
|
hours: 'Hours',
|
||||||
minutes: 'Minutes',
|
minutes: 'Minutes',
|
||||||
|
onMinute: 'On Minute',
|
||||||
days: 'Days',
|
days: 'Days',
|
||||||
lastDay: 'Last day',
|
lastDay: 'Last day',
|
||||||
lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.',
|
lastDayTooltip: 'Not all months have 31 days. Use the \'last day\' option to select each month\'s final day.',
|
||||||
@ -969,11 +968,10 @@ const translation = {
|
|||||||
invalidFrequency: 'Invalid frequency',
|
invalidFrequency: 'Invalid frequency',
|
||||||
invalidStartTime: 'Invalid start time',
|
invalidStartTime: 'Invalid start time',
|
||||||
startTimeMustBeFuture: 'Start time must be in the future',
|
startTimeMustBeFuture: 'Start time must be in the future',
|
||||||
invalidRecurEvery: 'Recur every must be between 1 and 999',
|
|
||||||
invalidRecurUnit: 'Invalid recur unit',
|
|
||||||
invalidTimeFormat: 'Invalid time format (expected HH:MM AM/PM)',
|
invalidTimeFormat: 'Invalid time format (expected HH:MM AM/PM)',
|
||||||
invalidWeekday: 'Invalid weekday: {{weekday}}',
|
invalidWeekday: 'Invalid weekday: {{weekday}}',
|
||||||
invalidMonthlyDay: 'Monthly day must be between 1-31 or "last"',
|
invalidMonthlyDay: 'Monthly day must be between 1-31 or "last"',
|
||||||
|
invalidOnMinute: 'On minute must be between 0-59',
|
||||||
invalidExecutionTime: 'Invalid execution time',
|
invalidExecutionTime: 'Invalid execution time',
|
||||||
executionTimeMustBeFuture: 'Execution time must be in the future',
|
executionTimeMustBeFuture: 'Execution time must be in the future',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -948,7 +948,6 @@ const translation = {
|
|||||||
executeNow: '今すぐ実行',
|
executeNow: '今すぐ実行',
|
||||||
weekdays: '曜日',
|
weekdays: '曜日',
|
||||||
selectDateTime: '日時を選択',
|
selectDateTime: '日時を選択',
|
||||||
recurEvery: '間隔',
|
|
||||||
cronExpression: 'Cron 式',
|
cronExpression: 'Cron 式',
|
||||||
selectFrequency: '頻度を選択',
|
selectFrequency: '頻度を選択',
|
||||||
lastDay: '月末',
|
lastDay: '月末',
|
||||||
@ -968,8 +967,6 @@ const translation = {
|
|||||||
invalidFrequency: '無効な頻度',
|
invalidFrequency: '無効な頻度',
|
||||||
invalidStartTime: '無効な開始時間',
|
invalidStartTime: '無効な開始時間',
|
||||||
startTimeMustBeFuture: '開始時間は未来の時間である必要があります',
|
startTimeMustBeFuture: '開始時間は未来の時間である必要があります',
|
||||||
invalidRecurEvery: '繰り返し間隔は1から999の間である必要があります',
|
|
||||||
invalidRecurUnit: '無効な繰り返し単位',
|
|
||||||
invalidTimeFormat: '無効な時間形式(期待される形式:HH:MM AM/PM)',
|
invalidTimeFormat: '無効な時間形式(期待される形式:HH:MM AM/PM)',
|
||||||
invalidWeekday: '無効な曜日:{{weekday}}',
|
invalidWeekday: '無効な曜日:{{weekday}}',
|
||||||
invalidMonthlyDay: '月の日は1-31の間または"last"である必要があります',
|
invalidMonthlyDay: '月の日は1-31の間または"last"である必要があります',
|
||||||
|
|||||||
@ -940,7 +940,6 @@ const translation = {
|
|||||||
selectFrequency: '选择频率',
|
selectFrequency: '选择频率',
|
||||||
nextExecutionTimes: '接下来 5 次执行时间',
|
nextExecutionTimes: '接下来 5 次执行时间',
|
||||||
hours: '小时',
|
hours: '小时',
|
||||||
recurEvery: '每隔',
|
|
||||||
minutes: '分钟',
|
minutes: '分钟',
|
||||||
cronExpression: 'Cron 表达式',
|
cronExpression: 'Cron 表达式',
|
||||||
weekdays: '星期',
|
weekdays: '星期',
|
||||||
@ -968,8 +967,6 @@ const translation = {
|
|||||||
invalidFrequency: '无效的频率',
|
invalidFrequency: '无效的频率',
|
||||||
invalidStartTime: '无效的开始时间',
|
invalidStartTime: '无效的开始时间',
|
||||||
startTimeMustBeFuture: '开始时间必须是将来的时间',
|
startTimeMustBeFuture: '开始时间必须是将来的时间',
|
||||||
invalidRecurEvery: '重复间隔必须在 1 到 999 之间',
|
|
||||||
invalidRecurUnit: '无效的重复单位',
|
|
||||||
invalidTimeFormat: '无效的时间格式(预期格式:HH:MM AM/PM)',
|
invalidTimeFormat: '无效的时间格式(预期格式:HH:MM AM/PM)',
|
||||||
invalidWeekday: '无效的工作日:{{weekday}}',
|
invalidWeekday: '无效的工作日:{{weekday}}',
|
||||||
invalidMonthlyDay: '月份日期必须在 1-31 之间或为"last"',
|
invalidMonthlyDay: '月份日期必须在 1-31 之间或为"last"',
|
||||||
|
|||||||
Reference in New Issue
Block a user