From b8401f1a3e6117e3aec224c638e2a66f21e8f8c8 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 12 May 2026 15:13:58 +1000 Subject: [PATCH] feat: added quarterly option for scheduler (#9076) --- .../src/components/schedule/CronPicker.tsx | 245 +++++++++++------- .../schedule/__tests__/CronPicker.test.tsx | 153 +++++++++++ ui/desktop/src/i18n/messages/en.json | 21 ++ ui/desktop/src/utils/cronSchedule.ts | 184 +++++++++++++ 4 files changed, 513 insertions(+), 90 deletions(-) create mode 100644 ui/desktop/src/components/schedule/__tests__/CronPicker.test.tsx create mode 100644 ui/desktop/src/utils/cronSchedule.ts diff --git a/ui/desktop/src/components/schedule/CronPicker.tsx b/ui/desktop/src/components/schedule/CronPicker.tsx index 8e9cf406..a34985bf 100644 --- a/ui/desktop/src/components/schedule/CronPicker.tsx +++ b/ui/desktop/src/components/schedule/CronPicker.tsx @@ -1,18 +1,39 @@ import React, { useState, useEffect } from 'react'; -import cronstrue from 'cronstrue'; import { ScheduledJob } from '../../schedule'; import { errorMessage } from '../../utils/conversionUtils'; import { defineMessages, useIntl } from '../../i18n'; +import { + buildCronForPeriod, + describeCron, + getQuarterStartMonth, + getValidDayOfMonth, + parseCron, + quarterDayLimitByStartMonth, + type Period, +} from '../../utils/cronSchedule'; const i18n = defineMessages({ every: { id: 'cronPicker.every', defaultMessage: 'Every' }, + mode: { id: 'cronPicker.mode', defaultMessage: 'Mode' }, minute: { id: 'cronPicker.minute', defaultMessage: 'Minute' }, hour: { id: 'cronPicker.hour', defaultMessage: 'Hour' }, day: { id: 'cronPicker.day', defaultMessage: 'Day' }, week: { id: 'cronPicker.week', defaultMessage: 'Week' }, month: { id: 'cronPicker.month', defaultMessage: 'Month' }, + quarter: { id: 'cronPicker.quarter', defaultMessage: 'Quarter' }, year: { id: 'cronPicker.year', defaultMessage: 'Year' }, + custom: { id: 'cronPicker.custom', defaultMessage: 'Custom cron' }, + cronExpression: { id: 'cronPicker.cronExpression', defaultMessage: 'Cron expression' }, + emptyCronError: { + id: 'cronPicker.emptyCronError', + defaultMessage: 'Cron expression cannot be empty', + }, + invalidDayOfMonth: { + id: 'cronPicker.invalidDayOfMonth', + defaultMessage: 'Day must be between 1 and {max}', + }, inMonth: { id: 'cronPicker.inMonth', defaultMessage: 'in' }, + startingMonth: { id: 'cronPicker.startingMonth', defaultMessage: 'starting month' }, january: { id: 'cronPicker.january', defaultMessage: 'January' }, february: { id: 'cronPicker.february', defaultMessage: 'February' }, march: { id: 'cronPicker.march', defaultMessage: 'March' }, @@ -39,61 +60,12 @@ const i18n = defineMessages({ atSecond: { id: 'cronPicker.atSecond', defaultMessage: 'at second' }, }); -type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; - -type ParsedCron = { - period: Period; - second: string; - minute: string; - hour: string; - dayOfMonth: string; - month: string; - dayOfWeek: string; -}; - interface CronPickerProps { schedule: ScheduledJob | null; onChange: (cron: string) => void; isValid: (valid: boolean) => void; } -const parseCron = (cron: string): ParsedCron => { - const parts = cron.split(' '); - if (parts.length === 5) { - parts.unshift('0'); - } - if (parts.length !== 6) { - return { - period: 'day', - second: '0', - minute: '0', - hour: '14', - dayOfMonth: '*', - month: '*', - dayOfWeek: '*', - }; - } - - const [second, minute, hour, dayOfMonth, month, dayOfWeek] = parts; - - if (month !== '*' && dayOfMonth !== '*') { - return { period: 'year', second, minute, hour, dayOfMonth, month, dayOfWeek }; - } - if (dayOfMonth !== '*') { - return { period: 'month', second, minute, hour, dayOfMonth, month, dayOfWeek }; - } - if (dayOfWeek !== '*') { - return { period: 'week', second, minute, hour, dayOfMonth, month, dayOfWeek }; - } - if (hour !== '*') { - return { period: 'day', second, minute, hour, dayOfMonth, month, dayOfWeek }; - } - if (minute !== '*') { - return { period: 'hour', second, minute, hour, dayOfMonth, month, dayOfWeek }; - } - return { period: 'minute', second, minute, hour, dayOfMonth, month, dayOfWeek }; -}; - const to24Hour = (hour12: number, isPM: boolean): number => { if (hour12 === 12) { return isPM ? 12 : 0; @@ -124,10 +96,27 @@ export const CronPicker: React.FC = ({ schedule, onChange, isVa const [dayOfWeek, setDayOfWeek] = useState('1'); const [dayOfMonth, setDayOfMonth] = useState('1'); const [month, setMonth] = useState('1'); + const [quarterStartMonth, setQuarterStartMonth] = useState('1'); + const [customCron, setCustomCron] = useState('0 0 14 * * *'); const [readableCron, setReadableCron] = useState(''); + const [hasCronError, setHasCronError] = useState(false); + + const getCurrentCron = (selectedPeriod: Period, validDayOfMonth: string | null): string => + buildCronForPeriod({ + period: selectedPeriod, + second, + minute, + hour24: to24Hour(hour12, isPM), + dayOfWeek, + dayOfMonth: validDayOfMonth, + month, + quarterStartMonth, + customCron, + }); useEffect(() => { - const parsed = parseCron(schedule?.cron || ''); + const sourceCron = schedule?.cron || ''; + const parsed = parseCron(sourceCron); setPeriod(parsed.period); setSecond(parsed.second === '*' ? '0' : parsed.second); setMinute(parsed.minute === '*' ? '0' : parsed.minute); @@ -138,57 +127,82 @@ export const CronPicker: React.FC = ({ schedule, onChange, isVa setDayOfWeek(parsed.dayOfWeek === '*' ? '1' : parsed.dayOfWeek); setDayOfMonth(parsed.dayOfMonth === '*' ? '1' : parsed.dayOfMonth); setMonth(parsed.month === '*' ? '1' : parsed.month); + setQuarterStartMonth(getQuarterStartMonth(parsed.month) ?? '1'); + setCustomCron(sourceCron || '0 0 14 * * *'); }, [schedule]); - useEffect(() => { - const hour24 = to24Hour(hour12, isPM); - let cron: string; + const maxDayOfMonth = period === 'quarter' ? quarterDayLimitByStartMonth[quarterStartMonth] : 31; - switch (period) { - case 'minute': - cron = `${second} * * * * *`; - break; - case 'hour': - cron = `${second} ${minute} * * * *`; - break; - case 'day': - cron = `${second} ${minute} ${hour24} * * *`; - break; - case 'week': - cron = `${second} ${minute} ${hour24} * * ${dayOfWeek}`; - break; - case 'month': - cron = `${second} ${minute} ${hour24} ${dayOfMonth} * *`; - break; - case 'year': - cron = `${second} ${minute} ${hour24} ${dayOfMonth} ${month} *`; - break; - default: - cron = '0 0 0 * * *'; + useEffect(() => { + const parsedDay = parseInt(dayOfMonth, 10); + if (!Number.isNaN(parsedDay) && parsedDay > maxDayOfMonth) { + setDayOfMonth(maxDayOfMonth.toString()); } + }, [dayOfMonth, maxDayOfMonth]); + + useEffect(() => { + const validDayOfMonth = getValidDayOfMonth(dayOfMonth, maxDayOfMonth); + + if ( + (period === 'month' || period === 'quarter' || period === 'year') && + validDayOfMonth === null + ) { + onChange(getCurrentCron(period, null)); + isValid(false); + setHasCronError(true); + setReadableCron(intl.formatMessage(i18n.invalidDayOfMonth, { max: maxDayOfMonth })); + return; + } + + const cron = getCurrentCron(period, validDayOfMonth); onChange(cron); - if (cron) { - const cronWithoutSeconds = cron.split(' ').slice(1).join(' '); - try { - setReadableCron(cronstrue.toString(cronWithoutSeconds)); - isValid(true); - } catch (e) { - isValid(false); - setReadableCron('error: ' + errorMessage(e)); - } + if (!cron.trim()) { + isValid(false); + setHasCronError(true); + setReadableCron(intl.formatMessage(i18n.emptyCronError)); + return; + } + try { + setReadableCron(describeCron(cron)); + setHasCronError(false); + isValid(true); + } catch (e) { + isValid(false); + setHasCronError(true); + setReadableCron(errorMessage(e).replace(/^Error:\s*/, '')); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [period, second, minute, hour12, isPM, dayOfWeek, dayOfMonth, month]); + }, [ + period, + second, + minute, + hour12, + isPM, + dayOfWeek, + dayOfMonth, + month, + quarterStartMonth, + maxDayOfMonth, + customCron, + ]); const selectClassName = 'px-2 py-1 border rounded bg-white dark:bg-gray-800 dark:border-gray-600'; return (
- {intl.formatMessage(i18n.every)} + + {intl.formatMessage(period === 'custom' ? i18n.mode : i18n.every)} +
+ {period === 'custom' && ( +
+ + setCustomCron(e.target.value)} + className="w-full px-2 py-1 border rounded" + /> +
+ )} + + {period === 'quarter' && ( +
+
+ {intl.formatMessage(i18n.startingMonth)} + +
+
+ {intl.formatMessage(i18n.onDay)} + setDayOfMonth(e.target.value)} + className="w-16 px-2 py-1 border rounded" + /> +
+
+ )} + {period === 'year' && (
{intl.formatMessage(i18n.inMonth)} @@ -231,7 +290,7 @@ export const CronPicker: React.FC = ({ schedule, onChange, isVa setDayOfMonth(e.target.value)} className="w-16 px-2 py-1 border rounded" @@ -258,7 +317,11 @@ export const CronPicker: React.FC = ({ schedule, onChange, isVa
)} - {(period === 'day' || period === 'week' || period === 'month' || period === 'year') && ( + {(period === 'day' || + period === 'week' || + period === 'month' || + period === 'quarter' || + period === 'year') && (
{intl.formatMessage(i18n.at)} = ({ schedule, onChange, isVa )}
-
{readableCron}
+
+ {readableCron} +
); }; diff --git a/ui/desktop/src/components/schedule/__tests__/CronPicker.test.tsx b/ui/desktop/src/components/schedule/__tests__/CronPicker.test.tsx new file mode 100644 index 00000000..3a1cc94a --- /dev/null +++ b/ui/desktop/src/components/schedule/__tests__/CronPicker.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, type RenderOptions, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { IntlTestWrapper } from '../../../i18n/test-utils'; +import { CronPicker } from '../CronPicker'; +import type { ScheduledJob } from '../../../schedule'; + +const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) => + render(ui, { wrapper: IntlTestWrapper, ...options }); + +const getLastCron = (onChange: ReturnType) => { + const calls = onChange.mock.calls; + return calls[calls.length - 1]?.[0]; +}; + +const scheduledJob = (cron: string): ScheduledJob => ({ + id: 'quarterly-report', + source: 'dummy.yaml', + cron, +}); + +describe('CronPicker', () => { + it('generates quarterly cron expressions from the quarter preset', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithIntl(); + + await user.selectOptions(screen.getAllByRole('combobox')[0], 'quarter'); + + await waitFor(() => { + expect(getLastCron(onChange)).toBe('0 0 14 1 1,4,7,10 *'); + }); + + const dayInput = screen.getAllByRole('spinbutton')[0]; + await user.clear(dayInput); + await user.type(dayInput, '31'); + await user.selectOptions(screen.getAllByRole('combobox')[1], '2'); + + await waitFor(() => { + expect(dayInput).toHaveValue(28); + expect(getLastCron(onChange)).toBe('0 0 14 28 2,5,8,11 *'); + }); + }); + + it('marks invalid quarter day input as invalid instead of silently clamping to day one', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const isValid = vi.fn(); + + renderWithIntl(); + + await user.selectOptions(screen.getAllByRole('combobox')[0], 'quarter'); + const dayInput = screen.getAllByRole('spinbutton')[0]; + await user.clear(dayInput); + await user.type(dayInput, '0'); + + await waitFor(() => { + expect(dayInput).toHaveValue(0); + expect(isValid).toHaveBeenLastCalledWith(false); + expect(getLastCron(onChange)).toBe('0 0 14 0 1,4,7,10 *'); + }); + }); + + it('uses custom cron for cron expressions that presets cannot represent', async () => { + const onChange = vi.fn(); + + renderWithIntl( + + ); + + const [periodSelect] = screen.getAllByRole('combobox'); + + await waitFor(() => { + expect(periodSelect).toHaveValue('custom'); + expect(screen.getByLabelText('Cron expression')).toHaveValue('0 0 14 31 1,4,7,10 *'); + expect(getLastCron(onChange)).toBe('0 0 14 31 1,4,7,10 *'); + }); + }); + + it('uses custom cron when seconds cannot be represented by presets', async () => { + const onChange = vi.fn(); + + renderWithIntl( + + ); + + const [periodSelect] = screen.getAllByRole('combobox'); + + await waitFor(() => { + expect(periodSelect).toHaveValue('custom'); + expect(screen.getByLabelText('Cron expression')).toHaveValue('* 0 14 * * *'); + expect(getLastCron(onChange)).toBe('* 0 14 * * *'); + }); + }); + + it('generates cron from custom cron input', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithIntl(); + + await user.selectOptions(screen.getAllByRole('combobox')[0], 'custom'); + const customCronInput = screen.getByLabelText('Cron expression'); + await user.clear(customCronInput); + await user.type(customCronInput, '0 9 31 1,4,7,10 *'); + + await waitFor(() => { + expect(getLastCron(onChange)).toBe('0 9 31 1,4,7,10 *'); + }); + }); + + it('uses the current preset cron when switching to custom cron', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithIntl(); + + const [periodSelect] = screen.getAllByRole('combobox'); + await user.selectOptions(periodSelect, 'quarter'); + await user.selectOptions(screen.getAllByRole('combobox')[1], '2'); + + const dayInput = screen.getAllByRole('spinbutton')[0]; + await user.clear(dayInput); + await user.type(dayInput, '15'); + await user.selectOptions(periodSelect, 'custom'); + + await waitFor(() => { + expect(screen.getByLabelText('Cron expression')).toHaveValue('0 0 14 15 2,5,8,11 *'); + expect(getLastCron(onChange)).toBe('0 0 14 15 2,5,8,11 *'); + }); + }); + + it('marks invalid custom cron input as invalid', async () => { + const user = userEvent.setup(); + const isValid = vi.fn(); + + renderWithIntl(); + + await user.selectOptions(screen.getAllByRole('combobox')[0], 'custom'); + const customCronInput = screen.getByLabelText('Cron expression'); + await user.clear(customCronInput); + await user.type(customCronInput, '99 0 14 * * *'); + + await waitFor(() => { + expect(isValid).toHaveBeenLastCalledWith(false); + }); + }); +}); diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index fb8ae3b0..2afe2020 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -518,12 +518,21 @@ "cronPicker.august": { "defaultMessage": "August" }, + "cronPicker.cronExpression": { + "defaultMessage": "Cron expression" + }, + "cronPicker.custom": { + "defaultMessage": "Custom cron" + }, "cronPicker.day": { "defaultMessage": "Day" }, "cronPicker.december": { "defaultMessage": "December" }, + "cronPicker.emptyCronError": { + "defaultMessage": "Cron expression cannot be empty" + }, "cronPicker.every": { "defaultMessage": "Every" }, @@ -539,6 +548,9 @@ "cronPicker.inMonth": { "defaultMessage": "in" }, + "cronPicker.invalidDayOfMonth": { + "defaultMessage": "Day must be between 1 and {max}" + }, "cronPicker.january": { "defaultMessage": "January" }, @@ -557,6 +569,9 @@ "cronPicker.minute": { "defaultMessage": "Minute" }, + "cronPicker.mode": { + "defaultMessage": "Mode" + }, "cronPicker.monday": { "defaultMessage": "Monday" }, @@ -575,12 +590,18 @@ "cronPicker.onDay": { "defaultMessage": "on day" }, + "cronPicker.quarter": { + "defaultMessage": "Quarter" + }, "cronPicker.saturday": { "defaultMessage": "Saturday" }, "cronPicker.september": { "defaultMessage": "September" }, + "cronPicker.startingMonth": { + "defaultMessage": "starting month" + }, "cronPicker.sunday": { "defaultMessage": "Sunday" }, diff --git a/ui/desktop/src/utils/cronSchedule.ts b/ui/desktop/src/utils/cronSchedule.ts new file mode 100644 index 00000000..31b019c9 --- /dev/null +++ b/ui/desktop/src/utils/cronSchedule.ts @@ -0,0 +1,184 @@ +import cronstrue from 'cronstrue'; + +export type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom'; + +export const quarterMonthsByStartMonth: Record = { + '1': '1,4,7,10', + '2': '2,5,8,11', + '3': '3,6,9,12', +}; + +export const quarterDayLimitByStartMonth: Record = { + '1': 30, + '2': 28, + '3': 30, +}; + +export type ParsedCron = { + period: Period; + second: string; + minute: string; + hour: string; + dayOfMonth: string; + month: string; + dayOfWeek: string; +}; + +export type CronParts = { + period: Period; + second: string; + minute: string; + hour24: number; + dayOfWeek: string; + dayOfMonth: string | null; + month: string; + quarterStartMonth: string; + customCron: string; +}; + +export const defaultParsedCron: ParsedCron = { + period: 'day', + second: '0', + minute: '0', + hour: '14', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', +}; + +export const getQuarterStartMonth = (month: string): string | null => { + const entry = Object.entries(quarterMonthsByStartMonth).find( + ([, quarterMonths]) => quarterMonths === month + ); + return entry?.[0] ?? null; +}; + +const normalizeCronParts = (cron: string): string[] | null => { + const parts = cron.trim().split(/\s+/); + if (parts.length === 5) { + return ['0', ...parts]; + } + if (parts.length === 6) { + return parts; + } + return null; +}; + +export const isSingleNumericValue = (value: string): boolean => /^\d+$/.test(value); + +export const getValidDayOfMonth = (value: string, max: number): string | null => { + if (!isSingleNumericValue(value)) { + return null; + } + const parsedDay = parseInt(value, 10); + if (parsedDay < 1 || parsedDay > max) { + return null; + } + return parsedDay.toString(); +}; + +const asCustomCron = (parts: string[]): ParsedCron => { + const [second, minute, hour, dayOfMonth, month, dayOfWeek] = parts; + return { period: 'custom', second, minute, hour, dayOfMonth, month, dayOfWeek }; +}; + +export const describeCron = (cron: string): string => { + const parts = cron.trim().split(/\s+/); + if (parts.length === 5 || parts.length === 6) { + return cronstrue.toString(parts.join(' ')); + } + throw new Error('Expected 5 or 6 fields'); +}; + +export const parseCron = (cron: string): ParsedCron => { + if (!cron.trim()) { + return defaultParsedCron; + } + + const parts = normalizeCronParts(cron); + if (!parts) { + return { ...defaultParsedCron, period: 'custom' }; + } + + const [second, minute, hour, dayOfMonth, month, dayOfWeek] = parts; + + if (!isSingleNumericValue(second)) { + return asCustomCron(parts); + } + + if (dayOfMonth !== '*') { + const quarterStartMonth = getQuarterStartMonth(month); + const dayOfMonthNumber = parseInt(dayOfMonth, 10); + if ( + quarterStartMonth && + isSingleNumericValue(dayOfMonth) && + dayOfMonthNumber <= quarterDayLimitByStartMonth[quarterStartMonth] + ) { + return { period: 'quarter', second, minute, hour, dayOfMonth, month, dayOfWeek }; + } + } + if (month !== '*' && dayOfMonth !== '*') { + if (!isSingleNumericValue(month) || !isSingleNumericValue(dayOfMonth)) { + return asCustomCron(parts); + } + return { period: 'year', second, minute, hour, dayOfMonth, month, dayOfWeek }; + } + if (dayOfMonth !== '*') { + if (!isSingleNumericValue(dayOfMonth)) { + return asCustomCron(parts); + } + return { period: 'month', second, minute, hour, dayOfMonth, month, dayOfWeek }; + } + if (dayOfWeek !== '*') { + if (!isSingleNumericValue(dayOfWeek)) { + return asCustomCron(parts); + } + return { period: 'week', second, minute, hour, dayOfMonth, month, dayOfWeek }; + } + if (hour !== '*') { + if (!isSingleNumericValue(hour)) { + return asCustomCron(parts); + } + return { period: 'day', second, minute, hour, dayOfMonth, month, dayOfWeek }; + } + if (minute !== '*') { + if (!isSingleNumericValue(minute)) { + return asCustomCron(parts); + } + return { period: 'hour', second, minute, hour, dayOfMonth, month, dayOfWeek }; + } + return { period: 'minute', second, minute, hour, dayOfMonth, month, dayOfWeek }; +}; + +export const buildCronForPeriod = ({ + period, + second, + minute, + hour24, + dayOfWeek, + dayOfMonth, + month, + quarterStartMonth, + customCron, +}: CronParts): string => { + switch (period) { + case 'custom': + return customCron; + case 'minute': + return `${second} * * * * *`; + case 'hour': + return `${second} ${minute} * * * *`; + case 'day': + return `${second} ${minute} ${hour24} * * *`; + case 'week': + return `${second} ${minute} ${hour24} * * ${dayOfWeek}`; + case 'month': + return `${second} ${minute} ${hour24} ${dayOfMonth ?? '0'} * *`; + case 'quarter': + return `${second} ${minute} ${hour24} ${dayOfMonth ?? '0'} ${quarterMonthsByStartMonth[quarterStartMonth]} *`; + case 'year': + return `${second} ${minute} ${hour24} ${dayOfMonth ?? '0'} ${month} *`; + default: + return '0 0 0 * * *'; + } +};