feat: added quarterly option for scheduler (#9076)

This commit is contained in:
Lifei Zhou
2026-05-12 15:13:58 +10:00
committed by GitHub
parent b5288843db
commit b8401f1a3e
4 changed files with 513 additions and 90 deletions
+155 -90
View File
@@ -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<CronPickerProps> = ({ 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<CronPickerProps> = ({ 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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{intl.formatMessage(i18n.every)}</span>
<span className="text-sm font-medium">
{intl.formatMessage(period === 'custom' ? i18n.mode : i18n.every)}
</span>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as Period)}
onChange={(e) => {
const nextPeriod = e.target.value as Period;
if (nextPeriod === 'custom' && period !== 'custom') {
setCustomCron(getCurrentCron(period, getValidDayOfMonth(dayOfMonth, maxDayOfMonth)));
}
setPeriod(nextPeriod);
}}
className={selectClassName}
>
<option value="minute">{intl.formatMessage(i18n.minute)}</option>
@@ -196,11 +210,56 @@ export const CronPicker: React.FC<CronPickerProps> = ({ schedule, onChange, isVa
<option value="day">{intl.formatMessage(i18n.day)}</option>
<option value="week">{intl.formatMessage(i18n.week)}</option>
<option value="month">{intl.formatMessage(i18n.month)}</option>
<option value="quarter">{intl.formatMessage(i18n.quarter)}</option>
<option value="year">{intl.formatMessage(i18n.year)}</option>
<option value="custom">{intl.formatMessage(i18n.custom)}</option>
</select>
</div>
<div className="space-y-3">
{period === 'custom' && (
<div className="space-y-1">
<label htmlFor="custom-cron-expression" className="text-sm">
{intl.formatMessage(i18n.cronExpression)}
</label>
<input
id="custom-cron-expression"
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
className="w-full px-2 py-1 border rounded"
/>
</div>
)}
{period === 'quarter' && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-sm">{intl.formatMessage(i18n.startingMonth)}</span>
<select
value={quarterStartMonth}
onChange={(e) => setQuarterStartMonth(e.target.value)}
className={selectClassName}
>
<option value="1">{intl.formatMessage(i18n.january)}</option>
<option value="2">{intl.formatMessage(i18n.february)}</option>
<option value="3">{intl.formatMessage(i18n.march)}</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">{intl.formatMessage(i18n.onDay)}</span>
<input
type="number"
min="1"
max={maxDayOfMonth}
value={dayOfMonth}
onChange={(e) => setDayOfMonth(e.target.value)}
className="w-16 px-2 py-1 border rounded"
/>
</div>
</div>
)}
{period === 'year' && (
<div className="flex items-center gap-2">
<span className="text-sm">{intl.formatMessage(i18n.inMonth)}</span>
@@ -231,7 +290,7 @@ export const CronPicker: React.FC<CronPickerProps> = ({ schedule, onChange, isVa
<input
type="number"
min="1"
max="31"
max={maxDayOfMonth}
value={dayOfMonth}
onChange={(e) => setDayOfMonth(e.target.value)}
className="w-16 px-2 py-1 border rounded"
@@ -258,7 +317,11 @@ export const CronPicker: React.FC<CronPickerProps> = ({ schedule, onChange, isVa
</div>
)}
{(period === 'day' || period === 'week' || period === 'month' || period === 'year') && (
{(period === 'day' ||
period === 'week' ||
period === 'month' ||
period === 'quarter' ||
period === 'year') && (
<div className="flex items-center gap-2">
<span className="text-sm">{intl.formatMessage(i18n.at)}</span>
<input
@@ -318,7 +381,9 @@ export const CronPicker: React.FC<CronPickerProps> = ({ schedule, onChange, isVa
)}
</div>
<div className="text-xs text-gray-500 mt-2">{readableCron}</div>
<div className={`text-xs mt-2 ${hasCronError ? 'text-text-danger' : 'text-gray-500'}`}>
{readableCron}
</div>
</div>
);
};
@@ -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<typeof vi.fn>) => {
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(<CronPicker schedule={null} onChange={onChange} isValid={vi.fn()} />);
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(<CronPicker schedule={null} onChange={onChange} isValid={isValid} />);
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(
<CronPicker
schedule={scheduledJob('0 0 14 31 1,4,7,10 *')}
onChange={onChange}
isValid={vi.fn()}
/>
);
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(
<CronPicker schedule={scheduledJob('* 0 14 * * *')} onChange={onChange} isValid={vi.fn()} />
);
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(<CronPicker schedule={null} onChange={onChange} isValid={vi.fn()} />);
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(<CronPicker schedule={null} onChange={onChange} isValid={vi.fn()} />);
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(<CronPicker schedule={null} onChange={vi.fn()} isValid={isValid} />);
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);
});
});
});
+21
View File
@@ -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"
},
+184
View File
@@ -0,0 +1,184 @@
import cronstrue from 'cronstrue';
export type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
export const quarterMonthsByStartMonth: Record<string, string> = {
'1': '1,4,7,10',
'2': '2,5,8,11',
'3': '3,6,9,12',
};
export const quarterDayLimitByStartMonth: Record<string, number> = {
'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 * * *';
}
};