From 6eaea64b3f4b91062d7a3d78c5dfde8446dbbaf6 Mon Sep 17 00:00:00 2001 From: lyzno1 <92089059+lyzno1@users.noreply.github.com> Date: Wed, 20 Aug 2025 21:23:30 +0800 Subject: [PATCH] feat: implement multi-select monthly trigger schedule (#24247) --- .../__tests__/monthly-days-selector.test.tsx | 265 ++++++++++++++++++ .../__tests__/monthly-multiselect.test.ts | 221 +++++++++++++++ .../__tests__/monthly-validation.test.ts | 172 ++++++++++++ .../components/monthly-days-selector.tsx | 20 +- .../nodes/trigger-schedule/default.ts | 20 +- .../workflow/nodes/trigger-schedule/panel.tsx | 6 +- .../workflow/nodes/trigger-schedule/types.ts | 2 +- .../utils/execution-time-calculator.ts | 70 +++-- 8 files changed, 737 insertions(+), 39 deletions(-) create mode 100644 web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-days-selector.test.tsx create mode 100644 web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts create mode 100644 web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-validation.test.ts diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-days-selector.test.tsx b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-days-selector.test.tsx new file mode 100644 index 0000000000..9a7732ffcf --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-days-selector.test.tsx @@ -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 + +const mockTranslation = { + t: (key: string) => { + const translations: Record = { + '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( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '20' })) + expect(onChange).toHaveBeenCalledWith([15, 20]) + }) + + test('handles last day selection', () => { + const onChange = jest.fn() + + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '15' })) + expect(onChange).toHaveBeenCalledWith([1, 20]) + }) + + test('handles last day selection', () => { + const onChange = jest.fn() + + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + for (let i = 1; i <= 31; i++) + expect(screen.getByRole('button', { name: i.toString() })).toBeInTheDocument() + }) + + test('renders last day button', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Last' })).toBeInTheDocument() + }) + + test('displays correct label', () => { + render( + , + ) + + expect(screen.getByText('Days')).toBeInTheDocument() + }) + + test('applies correct grid layout', () => { + const { container } = render( + , + ) + + const gridRows = container.querySelectorAll('.grid-cols-7') + expect(gridRows).toHaveLength(5) + }) + }) + + describe('Accessibility', () => { + test('buttons are keyboard accessible', () => { + const onChange = jest.fn() + + render( + , + ) + + const button = screen.getByRole('button', { name: '20' }) + button.focus() + expect(document.activeElement).toBe(button) + }) + + test('last day button has tooltip', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Last' })).toBeInTheDocument() + }) + + test('selected state is visually distinct', () => { + render( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '20' })) + expect(onChange).toHaveBeenCalledWith([15, 20]) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts new file mode 100644 index 0000000000..8dc53d022c --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-multiselect.test.ts @@ -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) + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-validation.test.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-validation.test.ts new file mode 100644 index 0000000000..ca708aadab --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/monthly-validation.test.ts @@ -0,0 +1,172 @@ +import nodeDefault from '../default' + +const mockT = (key: string, options?: any) => { + const translations: Record = { + '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('') + }) + }) +}) diff --git a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx index 68936bf253..427ad20720 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx @@ -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