mirror of https://github.com/langgenius/dify.git
feat: implement multi-select monthly trigger schedule (#24247)
This commit is contained in:
parent
5303b50737
commit
6eaea64b3f
|
|
@ -0,0 +1,265 @@
|
|||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MonthlyDaysSelector from '../components/monthly-days-selector'
|
||||
|
||||
jest.mock('react-i18next')
|
||||
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
||||
|
||||
const mockTranslation = {
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'workflow.nodes.triggerSchedule.days': 'Days',
|
||||
'workflow.nodes.triggerSchedule.lastDay': 'Last',
|
||||
'workflow.nodes.triggerSchedule.lastDayTooltip': 'Last day of month',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
||||
})
|
||||
|
||||
describe('MonthlyDaysSelector', () => {
|
||||
describe('Single selection', () => {
|
||||
test('renders with single selected day', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[15]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button15 = screen.getByRole('button', { name: '15' })
|
||||
expect(button15).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
})
|
||||
|
||||
test('calls onChange when day is clicked', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[15]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '20' }))
|
||||
expect(onChange).toHaveBeenCalledWith([15, 20])
|
||||
})
|
||||
|
||||
test('handles last day selection', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={['last']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const lastButton = screen.getByRole('button', { name: 'Last' })
|
||||
expect(lastButton).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-select functionality', () => {
|
||||
test('renders with multiple selected days', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1, 15, 30]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '1' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(screen.getByRole('button', { name: '15' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(screen.getByRole('button', { name: '30' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
})
|
||||
|
||||
test('adds day to selection when clicked', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1, 15]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '20' }))
|
||||
expect(onChange).toHaveBeenCalledWith([1, 15, 20])
|
||||
})
|
||||
|
||||
test('removes day from selection when clicked', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1, 15, 20]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith([1, 20])
|
||||
})
|
||||
|
||||
test('handles last day selection', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1, 'last']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Last' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Last' }))
|
||||
expect(onChange).toHaveBeenCalledWith([1])
|
||||
})
|
||||
|
||||
test('handles empty selection array', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '10' }))
|
||||
expect(onChange).toHaveBeenCalledWith([10])
|
||||
})
|
||||
|
||||
test('supports mixed selection of numbers and last day', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[5, 15, 'last']}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '5' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(screen.getByRole('button', { name: '15' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(screen.getByRole('button', { name: 'Last' })).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component structure', () => {
|
||||
test('renders all day buttons from 1 to 31', () => {
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
for (let i = 1; i <= 31; i++)
|
||||
expect(screen.getByRole('button', { name: i.toString() })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders last day button', () => {
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Last' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('displays correct label', () => {
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Days')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('applies correct grid layout', () => {
|
||||
const { container } = render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[1]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const gridRows = container.querySelectorAll('.grid-cols-7')
|
||||
expect(gridRows).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
test('buttons are keyboard accessible', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[15]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: '20' })
|
||||
button.focus()
|
||||
expect(document.activeElement).toBe(button)
|
||||
})
|
||||
|
||||
test('last day button has tooltip', () => {
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={['last']}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Last' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('selected state is visually distinct', () => {
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[15]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectedButton = screen.getByRole('button', { name: '15' })
|
||||
const unselectedButton = screen.getByRole('button', { name: '16' })
|
||||
|
||||
expect(selectedButton).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(unselectedButton).toHaveClass('border-divider-subtle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default behavior', () => {
|
||||
test('handles interaction correctly', () => {
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<MonthlyDaysSelector
|
||||
selectedDays={[15]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '20' }))
|
||||
expect(onChange).toHaveBeenCalledWith([15, 20])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import { getNextExecutionTimes } from '../utils/execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
|
||||
const createMonthlyConfig = (monthlyDays: (number | 'last')[], time = '10:30 AM'): ScheduleTriggerNodeType => ({
|
||||
mode: 'visual',
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time,
|
||||
monthly_days: monthlyDays,
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
id: 'test',
|
||||
type: 'trigger-schedule',
|
||||
data: {},
|
||||
position: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
describe('Monthly Multi-Select Execution Time Calculator', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2024-01-15T08:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Multi-select functionality', () => {
|
||||
test('calculates execution times for multiple days in same month', () => {
|
||||
const config = createMonthlyConfig([1, 15, 30])
|
||||
const times = getNextExecutionTimes(config, 5)
|
||||
|
||||
expect(times).toHaveLength(5)
|
||||
expect(times[0].getDate()).toBe(30)
|
||||
expect(times[0].getMonth()).toBe(0)
|
||||
expect(times[1].getDate()).toBe(1)
|
||||
expect(times[1].getMonth()).toBe(1)
|
||||
expect(times[2].getDate()).toBe(15)
|
||||
expect(times[2].getMonth()).toBe(1)
|
||||
})
|
||||
|
||||
test('handles last day with multiple selections', () => {
|
||||
const config = createMonthlyConfig([1, 'last'])
|
||||
const times = getNextExecutionTimes(config, 4)
|
||||
|
||||
expect(times[0].getDate()).toBe(31)
|
||||
expect(times[0].getMonth()).toBe(0)
|
||||
expect(times[1].getDate()).toBe(1)
|
||||
expect(times[1].getMonth()).toBe(1)
|
||||
expect(times[2].getDate()).toBe(29)
|
||||
expect(times[2].getMonth()).toBe(1)
|
||||
})
|
||||
|
||||
test('skips invalid days in months with fewer days', () => {
|
||||
const config = createMonthlyConfig([30, 31])
|
||||
jest.setSystemTime(new Date('2024-01-01T08:00:00Z'))
|
||||
const times = getNextExecutionTimes(config, 6)
|
||||
|
||||
const febTimes = times.filter(t => t.getMonth() === 1)
|
||||
expect(febTimes.length).toBeGreaterThan(0)
|
||||
expect(febTimes[0].getDate()).toBe(29)
|
||||
})
|
||||
|
||||
test('sorts execution times chronologically', () => {
|
||||
const config = createMonthlyConfig([25, 5, 15])
|
||||
const times = getNextExecutionTimes(config, 6)
|
||||
|
||||
for (let i = 1; i < times.length; i++)
|
||||
expect(times[i].getTime()).toBeGreaterThan(times[i - 1].getTime())
|
||||
})
|
||||
|
||||
test('handles single day selection', () => {
|
||||
const config = createMonthlyConfig([15])
|
||||
const times = getNextExecutionTimes(config, 3)
|
||||
|
||||
expect(times).toHaveLength(3)
|
||||
expect(times[0].getDate()).toBe(15)
|
||||
expect(times[1].getDate()).toBe(15)
|
||||
expect(times[2].getDate()).toBe(15)
|
||||
|
||||
for (let i = 1; i < times.length; i++)
|
||||
expect(times[i].getTime()).toBeGreaterThan(times[i - 1].getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Single day configuration', () => {
|
||||
test('supports single day selection', () => {
|
||||
const config = createMonthlyConfig([15])
|
||||
const times = getNextExecutionTimes(config, 3)
|
||||
|
||||
expect(times).toHaveLength(3)
|
||||
expect(times[0].getDate()).toBe(15)
|
||||
expect(times[1].getDate()).toBe(15)
|
||||
expect(times[2].getDate()).toBe(15)
|
||||
})
|
||||
|
||||
test('supports last day selection', () => {
|
||||
const config = createMonthlyConfig(['last'])
|
||||
const times = getNextExecutionTimes(config, 3)
|
||||
|
||||
expect(times[0].getDate()).toBe(31)
|
||||
expect(times[0].getMonth()).toBe(0)
|
||||
expect(times[1].getDate()).toBe(29)
|
||||
expect(times[1].getMonth()).toBe(1)
|
||||
})
|
||||
|
||||
test('falls back to day 1 when no configuration provided', () => {
|
||||
const config: ScheduleTriggerNodeType = {
|
||||
mode: 'visual',
|
||||
frequency: 'monthly',
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
id: 'test',
|
||||
type: 'trigger-schedule',
|
||||
data: {},
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
const times = getNextExecutionTimes(config, 2)
|
||||
|
||||
expect(times).toHaveLength(2)
|
||||
expect(times[0].getDate()).toBe(1)
|
||||
expect(times[1].getDate()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('handles empty monthly_days array', () => {
|
||||
const config = createMonthlyConfig([])
|
||||
const times = getNextExecutionTimes(config, 2)
|
||||
|
||||
expect(times).toHaveLength(2)
|
||||
expect(times[0].getDate()).toBe(1)
|
||||
expect(times[1].getDate()).toBe(1)
|
||||
})
|
||||
|
||||
test('handles execution time that has already passed today', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00Z'))
|
||||
const config = createMonthlyConfig([15], '10:30 AM')
|
||||
const times = getNextExecutionTimes(config, 2)
|
||||
|
||||
expect(times[0].getMonth()).toBe(1)
|
||||
expect(times[0].getDate()).toBe(15)
|
||||
})
|
||||
|
||||
test('limits search to reasonable number of months', () => {
|
||||
const config = createMonthlyConfig([29, 30, 31])
|
||||
jest.setSystemTime(new Date('2024-03-01T08:00:00Z'))
|
||||
const times = getNextExecutionTimes(config, 50)
|
||||
|
||||
expect(times.length).toBeGreaterThan(0)
|
||||
expect(times.length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
test('handles duplicate days in selection', () => {
|
||||
const config = createMonthlyConfig([15, 15, 15])
|
||||
const times = getNextExecutionTimes(config, 4)
|
||||
|
||||
const uniqueDates = new Set(times.map(t => t.getTime()))
|
||||
expect(uniqueDates.size).toBe(times.length)
|
||||
})
|
||||
|
||||
test('correctly handles leap year February', () => {
|
||||
const config = createMonthlyConfig([29])
|
||||
jest.setSystemTime(new Date('2024-01-01T08:00:00Z'))
|
||||
const times = getNextExecutionTimes(config, 3)
|
||||
|
||||
expect(times[0].getDate()).toBe(29)
|
||||
expect(times[0].getMonth()).toBe(0)
|
||||
expect(times[1].getDate()).toBe(29)
|
||||
expect(times[1].getMonth()).toBe(1)
|
||||
})
|
||||
|
||||
test('handles non-leap year February', () => {
|
||||
const config = createMonthlyConfig([29])
|
||||
jest.setSystemTime(new Date('2023-01-01T08:00:00Z'))
|
||||
const times = getNextExecutionTimes(config, 3)
|
||||
|
||||
expect(times[0].getDate()).toBe(29)
|
||||
expect(times[0].getMonth()).toBe(0)
|
||||
expect(times[1].getDate()).toBe(28)
|
||||
expect(times[1].getMonth()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time handling', () => {
|
||||
test('respects specified execution time', () => {
|
||||
const config = createMonthlyConfig([1], '2:45 PM')
|
||||
const times = getNextExecutionTimes(config, 1)
|
||||
|
||||
expect(times[0].getHours()).toBe(14)
|
||||
expect(times[0].getMinutes()).toBe(45)
|
||||
})
|
||||
|
||||
test('handles AM/PM conversion correctly', () => {
|
||||
const configAM = createMonthlyConfig([1], '6:30 AM')
|
||||
const configPM = createMonthlyConfig([1], '6:30 PM')
|
||||
|
||||
const timesAM = getNextExecutionTimes(configAM, 1)
|
||||
const timesPM = getNextExecutionTimes(configPM, 1)
|
||||
|
||||
expect(timesAM[0].getHours()).toBe(6)
|
||||
expect(timesPM[0].getHours()).toBe(18)
|
||||
})
|
||||
|
||||
test('handles 12 AM and 12 PM correctly', () => {
|
||||
const config12AM = createMonthlyConfig([1], '12:00 AM')
|
||||
const config12PM = createMonthlyConfig([1], '12:00 PM')
|
||||
|
||||
const times12AM = getNextExecutionTimes(config12AM, 1)
|
||||
const times12PM = getNextExecutionTimes(config12PM, 1)
|
||||
|
||||
expect(times12AM[0].getHours()).toBe(0)
|
||||
expect(times12PM[0].getHours()).toBe(12)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import nodeDefault from '../default'
|
||||
|
||||
const mockT = (key: string, options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
'workflow.errorMsg.fieldRequired': `${options?.field} is required`,
|
||||
'workflow.nodes.triggerSchedule.monthlyDay': 'Monthly Day',
|
||||
'workflow.nodes.triggerSchedule.invalidMonthlyDay': 'Invalid monthly day',
|
||||
'workflow.nodes.triggerSchedule.time': 'Time',
|
||||
'workflow.nodes.triggerSchedule.invalidTimeFormat': 'Invalid time format',
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
|
||||
describe('Monthly Validation', () => {
|
||||
describe('Single day validation', () => {
|
||||
test('validates single day selection', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: [15],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
|
||||
test('validates last day selection', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: ['last' as const],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-day validation', () => {
|
||||
test('validates multiple day selection', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: [1, 15, 30],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
|
||||
test('validates mixed selection with last day', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: [1, 15, 'last' as const],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
|
||||
test('rejects empty array', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: [],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('Monthly Day is required')
|
||||
})
|
||||
|
||||
test('rejects invalid day in array', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: [1, 35, 15],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('Invalid monthly day')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('requires monthly configuration', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('Monthly Day is required')
|
||||
})
|
||||
|
||||
test('validates time format along with monthly_days', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: 'invalid-time',
|
||||
monthly_days: [1, 15],
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('Invalid time format')
|
||||
})
|
||||
|
||||
test('handles very large arrays', () => {
|
||||
const config = {
|
||||
mode: 'visual' as const,
|
||||
frequency: 'monthly' as const,
|
||||
visual_config: {
|
||||
time: '10:30 AM',
|
||||
monthly_days: Array.from({ length: 31 }, (_, i) => i + 1),
|
||||
},
|
||||
timezone: 'UTC',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const result = nodeDefault.checkValid(config, mockT)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errorMessage).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -4,13 +4,23 @@ import { RiQuestionLine } from '@remixicon/react'
|
|||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type MonthlyDaysSelectorProps = {
|
||||
selectedDay: number | 'last'
|
||||
onChange: (day: number | 'last') => void
|
||||
selectedDays: (number | 'last')[]
|
||||
onChange: (days: (number | 'last')[]) => void
|
||||
}
|
||||
|
||||
const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps) => {
|
||||
const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleDayClick = (day: number | 'last') => {
|
||||
const current = selectedDays || []
|
||||
const newSelected = current.includes(day)
|
||||
? current.filter(d => d !== day)
|
||||
: [...current, day]
|
||||
onChange(newSelected)
|
||||
}
|
||||
|
||||
const isDaySelected = (day: number | 'last') => selectedDays?.includes(day) || false
|
||||
|
||||
const days = Array.from({ length: 31 }, (_, i) => i + 1)
|
||||
const rows = [
|
||||
days.slice(0, 7),
|
||||
|
|
@ -33,11 +43,11 @@ const MonthlyDaysSelector = ({ selectedDay, onChange }: MonthlyDaysSelectorProps
|
|||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => onChange(day)}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`rounded-lg border bg-components-option-card-option-bg py-1 text-xs transition-colors ${
|
||||
day === 'last' ? 'col-span-2 min-w-0' : ''
|
||||
} ${
|
||||
selectedDay === day
|
||||
isDaySelected(day)
|
||||
? 'border-util-colors-blue-brand-blue-brand-600 text-text-secondary'
|
||||
: 'border-divider-subtle text-text-tertiary hover:border-divider-regular hover:text-text-secondary'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -77,14 +77,22 @@ const validateMonthlyConfig = (config: any, t: any): string => {
|
|||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
if (!config.monthly_day)
|
||||
const getMonthlyDays = (): (number | 'last')[] => {
|
||||
if (Array.isArray(config.monthly_days) && config.monthly_days.length > 0)
|
||||
return config.monthly_days
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const monthlyDays = getMonthlyDays()
|
||||
|
||||
if (monthlyDays.length === 0)
|
||||
return t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.triggerSchedule.monthlyDay') })
|
||||
|
||||
if (config.monthly_day !== 'last'
|
||||
&& (typeof config.monthly_day !== 'number'
|
||||
|| config.monthly_day < 1
|
||||
|| config.monthly_day > 31))
|
||||
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
|
||||
for (const day of monthlyDays) {
|
||||
if (day !== 'last' && (typeof day !== 'number' || day < 1 || day > 31))
|
||||
return t('workflow.nodes.triggerSchedule.invalidMonthlyDay')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,13 +141,13 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
|
|||
|
||||
{inputs.frequency === 'monthly' && (
|
||||
<MonthlyDaysSelector
|
||||
selectedDay={inputs.visual_config?.monthly_day || 1}
|
||||
onChange={(day) => {
|
||||
selectedDays={inputs.visual_config?.monthly_days || [1]}
|
||||
onChange={(days) => {
|
||||
const newInputs = {
|
||||
...inputs,
|
||||
visual_config: {
|
||||
...inputs.visual_config,
|
||||
monthly_day: day,
|
||||
monthly_days: days,
|
||||
},
|
||||
}
|
||||
setInputs(newInputs)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type VisualConfig = {
|
|||
weekdays?: string[]
|
||||
recur_every?: number
|
||||
recur_unit?: 'hours' | 'minutes'
|
||||
monthly_day?: number | 'last'
|
||||
monthly_days?: (number | 'last')[]
|
||||
}
|
||||
|
||||
export type ScheduleTriggerNodeType = CommonNodeType & {
|
||||
|
|
|
|||
|
|
@ -87,7 +87,14 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||
}
|
||||
}
|
||||
else if (data.frequency === 'monthly') {
|
||||
const selectedDay = data.visual_config?.monthly_day || 1
|
||||
const getSelectedDays = (): (number | 'last')[] => {
|
||||
if (data.visual_config?.monthly_days && data.visual_config.monthly_days.length > 0)
|
||||
return data.visual_config.monthly_days
|
||||
|
||||
return [1]
|
||||
}
|
||||
|
||||
const selectedDays = [...new Set(getSelectedDays())]
|
||||
const [time, period] = defaultTime.split(' ')
|
||||
const [hour, minute] = time.split(':')
|
||||
let displayHour = Number.parseInt(hour)
|
||||
|
|
@ -97,38 +104,53 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
|
|||
const now = getCurrentTime()
|
||||
let monthOffset = 0
|
||||
|
||||
const currentMonthExecution = (() => {
|
||||
const hasValidCurrentMonthExecution = selectedDays.some((selectedDay) => {
|
||||
const currentMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
|
||||
|
||||
let targetDay: number
|
||||
if (selectedDay === 'last')
|
||||
targetDay = daysInMonth
|
||||
else
|
||||
targetDay = Math.min(selectedDay as number, daysInMonth)
|
||||
|
||||
if (selectedDay === 'last') {
|
||||
const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate()
|
||||
targetDay = lastDayOfMonth
|
||||
}
|
||||
else {
|
||||
targetDay = Math.min(selectedDay as number, new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate())
|
||||
}
|
||||
const execution = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
return execution > now
|
||||
})
|
||||
|
||||
return new Date(currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
})()
|
||||
|
||||
if (currentMonthExecution <= now)
|
||||
if (!hasValidCurrentMonthExecution)
|
||||
monthOffset = 1
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + i, 1)
|
||||
let targetDay: number
|
||||
let monthsChecked = 0
|
||||
|
||||
if (selectedDay === 'last') {
|
||||
const lastDayOfMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||
targetDay = lastDayOfMonth
|
||||
}
|
||||
else {
|
||||
targetDay = Math.min(selectedDay as number, new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate())
|
||||
while (times.length < count && monthsChecked < 24) {
|
||||
const targetMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset + monthsChecked, 1)
|
||||
const daysInMonth = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0).getDate()
|
||||
|
||||
const monthlyExecutions: Date[] = []
|
||||
|
||||
for (const selectedDay of selectedDays) {
|
||||
let targetDay: number
|
||||
|
||||
if (selectedDay === 'last')
|
||||
targetDay = daysInMonth
|
||||
else
|
||||
targetDay = Math.min(selectedDay as number, daysInMonth)
|
||||
|
||||
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
|
||||
if (nextExecution > now)
|
||||
monthlyExecutions.push(nextExecution)
|
||||
}
|
||||
|
||||
const nextExecution = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), targetDay, displayHour, Number.parseInt(minute), 0, 0)
|
||||
times.push(nextExecution)
|
||||
monthlyExecutions.sort((a, b) => a.getTime() - b.getTime())
|
||||
|
||||
for (const execution of monthlyExecutions) {
|
||||
if (times.length >= count) break
|
||||
times.push(execution)
|
||||
}
|
||||
|
||||
monthsChecked++
|
||||
}
|
||||
}
|
||||
else if (data.frequency === 'once') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue