feat: added quarterly option for scheduler (#9076)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 * * *';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user