mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Add UTC timezone label to CRON trigger form (#14674)
- Added 'Cron will be triggered at UTC time' notice below trigger interval dropdown - Positioned correctly between dropdown and expression field to match design - Only shows when Custom CRON option is selected --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
parent
37ce5c48bb
commit
2af20a4cf0
36 changed files with 2093 additions and 121 deletions
|
|
@ -70,6 +70,7 @@
|
|||
"apollo-link-rest": "^0.9.0",
|
||||
"apollo-upload-client": "^17.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"cron-parser": "5.1.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"docx": "^9.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import type { WorkflowCronTrigger } from '@/workflow/types/Workflow';
|
||||
import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { type Locale } from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
|
||||
|
||||
const convertScheduleToCronExpression = (
|
||||
trigger: WorkflowCronTrigger,
|
||||
): string | null => {
|
||||
switch (trigger.settings.type) {
|
||||
case 'CUSTOM':
|
||||
return trigger.settings.pattern;
|
||||
case 'DAYS':
|
||||
return `${trigger.settings.schedule.minute} ${trigger.settings.schedule.hour} */${trigger.settings.schedule.day} * *`;
|
||||
case 'HOURS':
|
||||
return `${trigger.settings.schedule.minute} */${trigger.settings.schedule.hour} * * *`;
|
||||
case 'MINUTES':
|
||||
return `*/${trigger.settings.schedule.minute} * * * *`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerScheduleDescription = (
|
||||
trigger: WorkflowCronTrigger,
|
||||
localeCatalog?: Locale,
|
||||
): string | null => {
|
||||
const cronExpression = convertScheduleToCronExpression(trigger);
|
||||
|
||||
if (!cronExpression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return describeCronExpression(
|
||||
cronExpression,
|
||||
{ use24HourTimeFormat: true },
|
||||
localeCatalog,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t`Invalid cron expression`;
|
||||
return errorMessage;
|
||||
}
|
||||
};
|
||||
|
||||
const getNextExecutions = (cronExpression: string): Date[] => {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
tz: 'UTC',
|
||||
});
|
||||
return interval.take(3).map((date) => date.toDate());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledScheduleDescription = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledScheduleSubtext = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
`;
|
||||
|
||||
const StyledExecutionItem = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-family: monospace;
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
type CronExpressionHelperProps = {
|
||||
trigger: WorkflowCronTrigger;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
export const CronExpressionHelper = ({
|
||||
trigger,
|
||||
isVisible = true,
|
||||
}: CronExpressionHelperProps) => {
|
||||
const { timeZone, dateFormat, timeFormat } = useDateTimeFormat();
|
||||
const dateLocale = useRecoilValue(dateLocaleState);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cronExpression = convertScheduleToCronExpression(trigger);
|
||||
const customDescription = getTriggerScheduleDescription(
|
||||
trigger,
|
||||
dateLocale.localeCatalog,
|
||||
);
|
||||
|
||||
if (!cronExpression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isValid = true;
|
||||
let errorMessage = '';
|
||||
|
||||
try {
|
||||
CronExpressionParser.parse(cronExpression);
|
||||
} catch (error) {
|
||||
isValid = false;
|
||||
errorMessage = error instanceof Error ? error.message : t`Unknown error`;
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<InputHint danger>
|
||||
{errorMessage || t`Please check your cron expression syntax.`}
|
||||
</InputHint>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const nextExecutions = getNextExecutions(cronExpression);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledSection>
|
||||
<InputLabel>{t`Schedule`}</InputLabel>
|
||||
<StyledScheduleDescription>
|
||||
{customDescription}
|
||||
</StyledScheduleDescription>
|
||||
<StyledScheduleSubtext>
|
||||
{t`Schedule runs in UTC timezone.`}
|
||||
</StyledScheduleSubtext>
|
||||
</StyledSection>
|
||||
|
||||
{nextExecutions.length > 0 && (
|
||||
<StyledSection>
|
||||
<InputLabel>{t`Upcoming execution times (${timeZone})`}</InputLabel>
|
||||
{nextExecutions.slice(0, 3).map((execution, index) => (
|
||||
<StyledExecutionItem key={index}>
|
||||
{formatDateTimeString({
|
||||
value: execution.toISOString(),
|
||||
timeZone,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
localeCatalog: dateLocale.localeCatalog,
|
||||
})}
|
||||
</StyledExecutionItem>
|
||||
))}
|
||||
</StyledSection>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { WorkflowCronTrigger } from '@/workflow/types/Workflow';
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
const CronExpressionHelperComponent = lazy(() =>
|
||||
import('./CronExpressionHelper').then((module) => ({
|
||||
default: module.CronExpressionHelper,
|
||||
})),
|
||||
);
|
||||
|
||||
type CronExpressionHelperLazyProps = {
|
||||
trigger: WorkflowCronTrigger;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
export const CronExpressionHelperLazy = ({
|
||||
trigger,
|
||||
isVisible = true,
|
||||
}: CronExpressionHelperLazyProps) => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CronExpressionHelperComponent trigger={trigger} isVisible={isVisible} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene
|
|||
import { type WorkflowCronTrigger } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||
import { CronExpressionHelperLazy } from '@/workflow/workflow-trigger/components/CronExpressionHelperLazy';
|
||||
import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
|
||||
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
|
||||
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
|
||||
|
|
@ -14,7 +15,6 @@ import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTrigge
|
|||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import cron from 'cron-validate';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
|
|
@ -108,45 +108,51 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
{trigger.settings.type === 'CUSTOM' && (
|
||||
<FormTextFieldInput
|
||||
label={t`Expression`}
|
||||
placeholder="0 */1 * * *"
|
||||
error={errorMessagesVisible ? errorMessages.CUSTOM : undefined}
|
||||
onBlur={onBlur}
|
||||
hint="Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]"
|
||||
readonly={triggerOptions.readonly}
|
||||
defaultValue={trigger.settings.pattern}
|
||||
onChange={(newPattern: string) => {
|
||||
if (triggerOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
<>
|
||||
<FormTextFieldInput
|
||||
label={t`Expression`}
|
||||
placeholder="0 */1 * * *"
|
||||
error={errorMessagesVisible ? errorMessages.CUSTOM : undefined}
|
||||
onBlur={onBlur}
|
||||
hint={t`Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]`}
|
||||
readonly={triggerOptions.readonly}
|
||||
defaultValue={trigger.settings.pattern}
|
||||
onChange={async (newPattern: string) => {
|
||||
if (triggerOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cronValidator = cron(newPattern);
|
||||
const { CronExpressionParser } = await import('cron-parser');
|
||||
|
||||
if (cronValidator.isError() === true) {
|
||||
setErrorMessages({
|
||||
CUSTOM: `Invalid cron pattern, ${cronValidator
|
||||
.getError()[0]
|
||||
.replace(/\. \(Input cron:.*$/, '')}`,
|
||||
try {
|
||||
CronExpressionParser.parse(newPattern);
|
||||
} catch (error) {
|
||||
setErrorMessages({
|
||||
CUSTOM: `Invalid cron pattern: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
CUSTOM: undefined,
|
||||
}));
|
||||
|
||||
triggerOptions.onTriggerUpdate({
|
||||
...trigger,
|
||||
settings: {
|
||||
...trigger.settings,
|
||||
type: 'CUSTOM',
|
||||
pattern: newPattern,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
CUSTOM: undefined,
|
||||
}));
|
||||
|
||||
triggerOptions.onTriggerUpdate({
|
||||
...trigger,
|
||||
settings: {
|
||||
...trigger.settings,
|
||||
type: 'CUSTOM',
|
||||
pattern: newPattern,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
<CronExpressionHelperLazy
|
||||
trigger={trigger}
|
||||
isVisible={!!trigger.settings.pattern && !errorMessages.CUSTOM}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{trigger.settings.type === 'DAYS' && (
|
||||
<>
|
||||
|
|
@ -196,11 +202,11 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter number greater than 1"
|
||||
placeholder={t`Enter number greater than 1`}
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
<FormNumberFieldInput
|
||||
label={t`Trigger at hour`}
|
||||
label={t`Trigger at hour (UTC)`}
|
||||
error={errorMessagesVisible ? errorMessages.DAYS_hour : undefined}
|
||||
onBlur={onBlur}
|
||||
defaultValue={trigger.settings.schedule.hour}
|
||||
|
|
@ -216,7 +222,7 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
if (!isNumber(newHour) || newHour < 0 || newHour > 23) {
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
DAYS_hour: `Invalid hour value '${newHour}'. Should be integer between 0 and 23`,
|
||||
DAYS_hour: t`Invalid hour value '${newHour}'. Should be integer between 0 and 23`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -245,11 +251,11 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter number between 0 and 23"
|
||||
placeholder={t`Enter number between 0 and 23`}
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
<FormNumberFieldInput
|
||||
label={t`Trigger at minute`}
|
||||
label={t`Trigger at minute (UTC)`}
|
||||
error={
|
||||
errorMessagesVisible ? errorMessages.DAYS_minute : undefined
|
||||
}
|
||||
|
|
@ -267,7 +273,7 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
if (!isNumber(newMinute) || newMinute < 0 || newMinute > 59) {
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
DAYS_minute: `Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
|
||||
DAYS_minute: t`Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -296,9 +302,17 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter number between 0 and 59"
|
||||
placeholder={t`Enter number between 0 and 59`}
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
<CronExpressionHelperLazy
|
||||
trigger={trigger}
|
||||
isVisible={
|
||||
!errorMessages.DAYS_day &&
|
||||
!errorMessages.DAYS_hour &&
|
||||
!errorMessages.DAYS_minute
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{trigger.settings.type === 'HOURS' && (
|
||||
|
|
@ -322,7 +336,7 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
if (!isNumber(newHour) || newHour <= 0) {
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
HOURS_hour: `Invalid hour value '${newHour}'. Should be integer greater than 1`,
|
||||
HOURS_hour: t`Invalid hour value '${newHour}'. Should be integer greater than 1`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -347,11 +361,11 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter number greater than 1"
|
||||
placeholder={t`Enter number greater than 1`}
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
<FormNumberFieldInput
|
||||
label={t`Trigger at minute`}
|
||||
label={t`Trigger at minute (UTC)`}
|
||||
error={
|
||||
errorMessagesVisible ? errorMessages.HOURS_minute : undefined
|
||||
}
|
||||
|
|
@ -369,7 +383,7 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
if (!isNumber(newMinute) || newMinute < 0 || newMinute > 59) {
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
HOURS_minute: `Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
|
||||
HOURS_minute: t`Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -394,52 +408,64 @@ export const WorkflowEditTriggerCronForm = ({
|
|||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter number between 0 and 59"
|
||||
placeholder={t`Enter number between 0 and 59`}
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
<CronExpressionHelperLazy
|
||||
trigger={trigger}
|
||||
isVisible={
|
||||
!errorMessages.HOURS_hour && !errorMessages.HOURS_minute
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{trigger.settings.type === 'MINUTES' && (
|
||||
<FormNumberFieldInput
|
||||
label={t`Minutes between triggers`}
|
||||
error={errorMessagesVisible ? errorMessages.MINUTES : undefined}
|
||||
onBlur={onBlur}
|
||||
defaultValue={trigger.settings.schedule.minute}
|
||||
onChange={(newMinute) => {
|
||||
if (triggerOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
<>
|
||||
<FormNumberFieldInput
|
||||
label={t`Minutes between triggers`}
|
||||
error={errorMessagesVisible ? errorMessages.MINUTES : undefined}
|
||||
onBlur={onBlur}
|
||||
defaultValue={trigger.settings.schedule.minute}
|
||||
onChange={(newMinute) => {
|
||||
if (triggerOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(newMinute)) {
|
||||
return;
|
||||
}
|
||||
if (!isDefined(newMinute)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNumber(newMinute) || newMinute <= 0) {
|
||||
setErrorMessages({
|
||||
MINUTES: `Invalid minute value '${newMinute}'. Should be integer greater than 1`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isNumber(newMinute) || newMinute <= 0) {
|
||||
setErrorMessages({
|
||||
MINUTES: t`Invalid minute value '${newMinute}'. Should be integer greater than 1`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
MINUTES: undefined,
|
||||
}));
|
||||
setErrorMessages((prev) => ({
|
||||
...prev,
|
||||
MINUTES: undefined,
|
||||
}));
|
||||
|
||||
triggerOptions.onTriggerUpdate({
|
||||
...trigger,
|
||||
settings: {
|
||||
...trigger.settings,
|
||||
type: 'MINUTES',
|
||||
schedule: {
|
||||
minute: newMinute,
|
||||
triggerOptions.onTriggerUpdate({
|
||||
...trigger,
|
||||
settings: {
|
||||
...trigger.settings,
|
||||
type: 'MINUTES',
|
||||
schedule: {
|
||||
minute: newMinute,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="Enter number greater than 1"
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
});
|
||||
}}
|
||||
placeholder={t`Enter number greater than 1`}
|
||||
readonly={triggerOptions.readonly}
|
||||
/>
|
||||
<CronExpressionHelperLazy
|
||||
trigger={trigger}
|
||||
isVisible={!errorMessages.MINUTES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
|
||||
|
||||
describe('cronstrue comparison tests', () => {
|
||||
describe('comparing with cronstrue examples', () => {
|
||||
it('should handle "* * * * *" like cronstrue', () => {
|
||||
// cronstrue: "Every minute"
|
||||
expect(describeCronExpression('* * * * *')).toBe('every minute');
|
||||
});
|
||||
|
||||
it('should handle "0 23 * * 1-5" like cronstrue', () => {
|
||||
// cronstrue: "At 11:00 PM, Monday through Friday" (but we use 24h format)
|
||||
expect(describeCronExpression('0 23 * * 1-5')).toBe(
|
||||
'at 23:00 on weekdays',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle "0 23 * * *" like cronstrue', () => {
|
||||
// cronstrue: "At 11:00 PM, every day" (but we use 24h format and simpler wording)
|
||||
expect(describeCronExpression('0 23 * * *')).toBe('at 23:00');
|
||||
});
|
||||
|
||||
it('should handle "23 12 * * 0#2" like cronstrue', () => {
|
||||
// cronstrue: "At 12:23 PM, on the second Sunday of the month" (but we use 24h format)
|
||||
expect(describeCronExpression('23 12 * * 0#2')).toBe(
|
||||
'at 12:23 on the second Sunday of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle "23 14 * * 0#2" like cronstrue', () => {
|
||||
// cronstrue: "At 14:23, on the second Sunday of the month"
|
||||
expect(describeCronExpression('23 14 * * 0#2')).toBe(
|
||||
'at 14:23 on the second Sunday of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle "* * * 6-8 *" like cronstrue', () => {
|
||||
// cronstrue: "Every minute, July through September" (with monthStartIndexZero: true)
|
||||
// Our version uses standard indexing (1-12) so 6-8 = June-August
|
||||
expect(describeCronExpression('* * * 6-8 *')).toBe(
|
||||
'every minute between June and August',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('additional complex patterns', () => {
|
||||
it('should handle business hours patterns', () => {
|
||||
expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe(
|
||||
'every 15 minutes between 09:00 and 17:00 on weekdays',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle monthly patterns', () => {
|
||||
expect(describeCronExpression('0 9 1 */3 *')).toBe(
|
||||
'at 09:00 on the 1st of the month every 3 months',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle last day patterns', () => {
|
||||
expect(describeCronExpression('0 23 L * *')).toBe(
|
||||
'at 23:00 on the last day of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle last Friday patterns', () => {
|
||||
expect(describeCronExpression('0 17 * * 5L')).toBe(
|
||||
'at 17:00 on the last Friday of the month',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('12-hour format comparison', () => {
|
||||
it('should format in 12-hour when requested', () => {
|
||||
expect(
|
||||
describeCronExpression('0 14 * * *', { use24HourTimeFormat: false }),
|
||||
).toBe('at 2:00 PM');
|
||||
expect(
|
||||
describeCronExpression('23 12 * * 0#2', { use24HourTimeFormat: false }),
|
||||
).toBe('at 12:23 PM on the second Sunday of the month');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
|
||||
|
||||
describe('describeCronExpression', () => {
|
||||
describe('basic expressions', () => {
|
||||
it('should describe every minute', () => {
|
||||
expect(describeCronExpression('* * * * *')).toBe('every minute');
|
||||
});
|
||||
|
||||
it('should describe every 5 minutes', () => {
|
||||
expect(describeCronExpression('*/5 * * * *')).toBe('every 5 minutes');
|
||||
});
|
||||
|
||||
it('should describe every hour', () => {
|
||||
expect(describeCronExpression('0 * * * *')).toBe('every hour');
|
||||
});
|
||||
|
||||
it('should describe every 2 hours', () => {
|
||||
expect(describeCronExpression('0 */2 * * *')).toBe('every 2 hours');
|
||||
});
|
||||
|
||||
it('should describe daily at specific time', () => {
|
||||
expect(describeCronExpression('30 14 * * *')).toBe('at 14:30');
|
||||
});
|
||||
|
||||
it('should describe daily at midnight', () => {
|
||||
expect(describeCronExpression('0 0 * * *')).toBe('at 00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('day-specific expressions', () => {
|
||||
it('should describe every day', () => {
|
||||
expect(describeCronExpression('0 9 * * *')).toBe('at 09:00');
|
||||
});
|
||||
|
||||
it('should describe every 3 days', () => {
|
||||
expect(describeCronExpression('0 9 */3 * *')).toBe(
|
||||
'at 09:00 every 3 days',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe weekdays', () => {
|
||||
expect(describeCronExpression('0 9 * * 1-5')).toBe(
|
||||
'at 09:00 on weekdays',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe specific day of month', () => {
|
||||
expect(describeCronExpression('0 9 15 * *')).toBe(
|
||||
'at 09:00 on the 15th of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe last day of month', () => {
|
||||
expect(describeCronExpression('0 9 L * *')).toBe(
|
||||
'at 09:00 on the last day of the month',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('month-specific expressions', () => {
|
||||
it('should describe specific month', () => {
|
||||
expect(describeCronExpression('0 9 1 1 *')).toBe(
|
||||
'at 09:00 on the 1st of the month only in January',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe multiple months', () => {
|
||||
expect(describeCronExpression('0 9 * 1,6,12 *')).toBe(
|
||||
'at 09:00 only in January, June and December',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe month range', () => {
|
||||
expect(describeCronExpression('0 9 * 6-8 *')).toBe(
|
||||
'at 09:00 between June and August',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe every 3 months', () => {
|
||||
expect(describeCronExpression('0 9 1 */3 *')).toBe(
|
||||
'at 09:00 on the 1st of the month every 3 months',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex expressions', () => {
|
||||
it('should describe business hours every 15 minutes on weekdays', () => {
|
||||
expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe(
|
||||
'every 15 minutes between 09:00 and 17:00 on weekdays',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe first Monday of every month', () => {
|
||||
expect(describeCronExpression('0 9 * * 1#1')).toBe(
|
||||
'at 09:00 on the first Monday of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe last Friday of every month', () => {
|
||||
expect(describeCronExpression('0 17 * * 5L')).toBe(
|
||||
'at 17:00 on the last Friday of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe multiple specific times', () => {
|
||||
expect(describeCronExpression('0 9,12,15 * * *')).toBe(
|
||||
'at 09:00, 12:00 and 15:00',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe range of minutes', () => {
|
||||
expect(describeCronExpression('15-45 * * * *')).toBe(
|
||||
'between minute 15 and 45',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe specific minutes on specific hours', () => {
|
||||
expect(describeCronExpression('30 9,14 * * *')).toBe(
|
||||
'at 09:30 and 14:30',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world complex expressions', () => {
|
||||
it('should describe business hours every 15 minutes on weekdays', () => {
|
||||
expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe(
|
||||
'every 15 minutes between 09:00 and 17:00 on weekdays',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe quarterly reports', () => {
|
||||
expect(describeCronExpression('0 9 1 1,4,7,10 *')).toBe(
|
||||
'at 09:00 on the 1st of the month only in January, April, July and October',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe range of minutes', () => {
|
||||
expect(describeCronExpression('15-45 * * * *')).toBe(
|
||||
'between minute 15 and 45',
|
||||
);
|
||||
});
|
||||
|
||||
it('should describe reduced format expressions', () => {
|
||||
expect(describeCronExpression('9 * * *')).toBe('at 09:00');
|
||||
expect(describeCronExpression('*/2 * * *')).toBe('every 2 hours');
|
||||
expect(describeCronExpression('9 15 * *')).toBe(
|
||||
'at 09:00 on the 15th of the month',
|
||||
);
|
||||
expect(describeCronExpression('9 * * 1')).toBe('at 09:00 only on Monday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty expression', () => {
|
||||
expect(() => describeCronExpression('')).toThrow(
|
||||
'Cron expression is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid expression', () => {
|
||||
expect(() => describeCronExpression('invalid')).toThrow(
|
||||
'Failed to describe cron expression',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle expression with too many fields', () => {
|
||||
expect(() => describeCronExpression('0 0 0 0 0 0 0 0')).toThrow(
|
||||
'Failed to describe cron expression',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('12-hour format', () => {
|
||||
it('should use 12-hour format when specified', () => {
|
||||
expect(
|
||||
describeCronExpression('0 14 * * *', { use24HourTimeFormat: false }),
|
||||
).toBe('at 2:00 PM');
|
||||
});
|
||||
|
||||
it('should use 12-hour format for multiple times', () => {
|
||||
expect(
|
||||
describeCronExpression('0 9,14,18 * * *', {
|
||||
use24HourTimeFormat: false,
|
||||
}),
|
||||
).toBe('at 9:00 AM, 2:00 PM and 6:00 PM');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { getDayOfMonthDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription';
|
||||
import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
|
||||
|
||||
describe('getDayOfMonthDescription', () => {
|
||||
const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
|
||||
|
||||
it('should handle wildcard (every day)', () => {
|
||||
expect(getDayOfMonthDescription('*', options)).toBe('every day');
|
||||
});
|
||||
|
||||
it('should handle last day of month', () => {
|
||||
expect(getDayOfMonthDescription('L', options)).toBe(
|
||||
'on the last day of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single days', () => {
|
||||
expect(getDayOfMonthDescription('1', options)).toBe(
|
||||
'on the 1st of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('2', options)).toBe(
|
||||
'on the 2nd of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('3', options)).toBe(
|
||||
'on the 3rd of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('15', options)).toBe(
|
||||
'on the 15th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('21', options)).toBe(
|
||||
'on the 21st of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('22', options)).toBe(
|
||||
'on the 22nd of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('23', options)).toBe(
|
||||
'on the 23rd of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('31', options)).toBe(
|
||||
'on the 31st of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step values', () => {
|
||||
expect(getDayOfMonthDescription('*/5', options)).toBe('every 5 days');
|
||||
expect(getDayOfMonthDescription('*/1', options)).toBe('every day');
|
||||
expect(getDayOfMonthDescription('*/10', options)).toBe('every 10 days');
|
||||
});
|
||||
|
||||
it('should handle range with step', () => {
|
||||
expect(getDayOfMonthDescription('1-15/3', options)).toBe(
|
||||
'every 3 days, between the 1st and 15th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('10-20/2', options)).toBe(
|
||||
'every 2 days, between the 10th and 20th of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ranges', () => {
|
||||
expect(getDayOfMonthDescription('1-15', options)).toBe(
|
||||
'between the 1st and 15th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('10-20', options)).toBe(
|
||||
'between the 10th and 20th of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle lists', () => {
|
||||
expect(getDayOfMonthDescription('1,15', options)).toBe(
|
||||
'on the 1st and 15th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('1,10,20,31', options)).toBe(
|
||||
'on the 1st, 10th, 20th and 31st of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle weekday specifier', () => {
|
||||
expect(getDayOfMonthDescription('15W', options)).toBe(
|
||||
'on the weekday closest to the 15th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('1W', options)).toBe(
|
||||
'on the weekday closest to the 1st of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('W', options)).toBe('on weekdays only');
|
||||
});
|
||||
|
||||
it('should handle ordinal numbers correctly', () => {
|
||||
// Test ordinal suffix logic
|
||||
expect(getDayOfMonthDescription('11', options)).toBe(
|
||||
'on the 11th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('12', options)).toBe(
|
||||
'on the 12th of the month',
|
||||
);
|
||||
expect(getDayOfMonthDescription('13', options)).toBe(
|
||||
'on the 13th of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(getDayOfMonthDescription('', options)).toBe('');
|
||||
expect(getDayOfMonthDescription(' ', options)).toBe('');
|
||||
expect(getDayOfMonthDescription('invalid', options)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { getDayOfWeekDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription';
|
||||
import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
|
||||
|
||||
describe('getDayOfWeekDescription', () => {
|
||||
const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
|
||||
|
||||
it('should handle wildcard (every day)', () => {
|
||||
expect(getDayOfWeekDescription('*', options)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single days', () => {
|
||||
expect(getDayOfWeekDescription('0', options)).toBe('only on Sunday');
|
||||
expect(getDayOfWeekDescription('1', options)).toBe('only on Monday');
|
||||
expect(getDayOfWeekDescription('6', options)).toBe('only on Saturday');
|
||||
expect(getDayOfWeekDescription('7', options)).toBe('only on Sunday'); // 7 = Sunday
|
||||
});
|
||||
|
||||
it('should handle weekdays', () => {
|
||||
expect(getDayOfWeekDescription('1-5', options)).toBe('on weekdays');
|
||||
});
|
||||
|
||||
it('should handle weekends', () => {
|
||||
expect(getDayOfWeekDescription('0,6', options)).toBe(
|
||||
'only on Sunday and Saturday',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle day ranges', () => {
|
||||
expect(getDayOfWeekDescription('1-3', options)).toBe(
|
||||
'from Monday to Wednesday',
|
||||
);
|
||||
expect(getDayOfWeekDescription('4-6', options)).toBe(
|
||||
'from Thursday to Saturday',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle day lists', () => {
|
||||
expect(getDayOfWeekDescription('1,3,5', options)).toBe(
|
||||
'only on Monday, Wednesday and Friday',
|
||||
);
|
||||
expect(getDayOfWeekDescription('0,6', options)).toBe(
|
||||
'only on Sunday and Saturday',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step values', () => {
|
||||
expect(getDayOfWeekDescription('*/2', options)).toBe('every 2 days');
|
||||
expect(getDayOfWeekDescription('*/1', options)).toBe('every day');
|
||||
});
|
||||
|
||||
it('should handle nth occurrence of weekday', () => {
|
||||
expect(getDayOfWeekDescription('1#1', options)).toBe(
|
||||
'on the first Monday of the month',
|
||||
);
|
||||
expect(getDayOfWeekDescription('5#2', options)).toBe(
|
||||
'on the second Friday of the month',
|
||||
);
|
||||
expect(getDayOfWeekDescription('0#3', options)).toBe(
|
||||
'on the third Sunday of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle last occurrence of weekday', () => {
|
||||
expect(getDayOfWeekDescription('1L', options)).toBe(
|
||||
'on the last Monday of the month',
|
||||
);
|
||||
expect(getDayOfWeekDescription('5L', options)).toBe(
|
||||
'on the last Friday of the month',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different start index options', () => {
|
||||
const optionsStartFromOne = { ...options, dayOfWeekStartIndexZero: false };
|
||||
|
||||
// When dayOfWeekStartIndexZero is false, 1=Sunday, 2=Monday, etc.
|
||||
expect(getDayOfWeekDescription('2-6', optionsStartFromOne)).toBe(
|
||||
'on weekdays',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(getDayOfWeekDescription('', options)).toBe('');
|
||||
expect(getDayOfWeekDescription(' ', options)).toBe('');
|
||||
expect(getDayOfWeekDescription('invalid', options)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { getHoursDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription';
|
||||
import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
|
||||
|
||||
describe('getHoursDescription', () => {
|
||||
const options24 = {
|
||||
...DEFAULT_CRON_DESCRIPTION_OPTIONS,
|
||||
use24HourTimeFormat: true,
|
||||
};
|
||||
const options12 = {
|
||||
...DEFAULT_CRON_DESCRIPTION_OPTIONS,
|
||||
use24HourTimeFormat: false,
|
||||
};
|
||||
|
||||
describe('24-hour format', () => {
|
||||
it('should handle wildcard', () => {
|
||||
expect(getHoursDescription('*', '0', options24)).toBe('every hour');
|
||||
});
|
||||
|
||||
it('should handle step values', () => {
|
||||
expect(getHoursDescription('*/2', '0', options24)).toBe('every 2 hours');
|
||||
expect(getHoursDescription('*/1', '0', options24)).toBe('every hour');
|
||||
});
|
||||
|
||||
it('should handle range with step', () => {
|
||||
expect(getHoursDescription('9-17/2', '0', options24)).toBe(
|
||||
'every 2 hours, between 09:00 and 17:00',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ranges', () => {
|
||||
expect(getHoursDescription('9-17', '0', options24)).toBe(
|
||||
'between 09:00 and 17:00',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle lists', () => {
|
||||
expect(getHoursDescription('9,12', '0', options24)).toBe(
|
||||
'at 09:00 and 12:00',
|
||||
);
|
||||
expect(getHoursDescription('9,12,15,18', '30', options24)).toBe(
|
||||
'at 09:30, 12:30, 15:30 and 18:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single values', () => {
|
||||
expect(getHoursDescription('9', '30', options24)).toBe('at 09:30');
|
||||
expect(getHoursDescription('0', '0', options24)).toBe('at 00:00');
|
||||
expect(getHoursDescription('23', '59', options24)).toBe('at 23:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12-hour format', () => {
|
||||
it('should format morning times', () => {
|
||||
expect(getHoursDescription('9', '30', options12)).toBe('at 9:30 AM');
|
||||
expect(getHoursDescription('0', '0', options12)).toBe('at 12:00 AM');
|
||||
});
|
||||
|
||||
it('should format afternoon times', () => {
|
||||
expect(getHoursDescription('14', '30', options12)).toBe('at 2:30 PM');
|
||||
expect(getHoursDescription('12', '0', options12)).toBe('at 12:00 PM');
|
||||
});
|
||||
|
||||
it('should format lists in 12-hour', () => {
|
||||
expect(getHoursDescription('9,14', '0', options12)).toBe(
|
||||
'at 9:00 AM and 2:00 PM',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format ranges in 12-hour', () => {
|
||||
expect(getHoursDescription('9-17', '0', options12)).toBe(
|
||||
'between 9:00 AM and 5:00 PM',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty hours', () => {
|
||||
expect(getHoursDescription('', '0', options24)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle invalid hours', () => {
|
||||
expect(getHoursDescription('invalid', '0', options24)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { getMinutesDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription';
|
||||
import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
|
||||
|
||||
describe('getMinutesDescription', () => {
|
||||
const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
|
||||
|
||||
it('should handle wildcard', () => {
|
||||
expect(getMinutesDescription('*', options)).toBe('every minute');
|
||||
});
|
||||
|
||||
it('should handle step values', () => {
|
||||
expect(getMinutesDescription('*/5', options)).toBe('every 5 minutes');
|
||||
expect(getMinutesDescription('*/15', options)).toBe('every 15 minutes');
|
||||
expect(getMinutesDescription('*/1', options)).toBe('every minute');
|
||||
});
|
||||
|
||||
it('should handle range with step', () => {
|
||||
expect(getMinutesDescription('10-30/5', options)).toBe(
|
||||
'every 5 minutes, between minute 10 and 30',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ranges', () => {
|
||||
expect(getMinutesDescription('10-20', options)).toBe(
|
||||
'between minute 10 and 20',
|
||||
);
|
||||
expect(getMinutesDescription('0-59', options)).toBe(
|
||||
'between minute 0 and 59',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle lists', () => {
|
||||
expect(getMinutesDescription('10,20', options)).toBe(
|
||||
'at minutes 10 and 20',
|
||||
);
|
||||
expect(getMinutesDescription('0,15,30,45', options)).toBe(
|
||||
'at minutes 0, 15, 30 and 45',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single values', () => {
|
||||
expect(getMinutesDescription('0', options)).toBe('at the top of the hour');
|
||||
expect(getMinutesDescription('1', options)).toBe(
|
||||
'at 1 minute past the hour',
|
||||
);
|
||||
expect(getMinutesDescription('30', options)).toBe(
|
||||
'at 30 minutes past the hour',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(getMinutesDescription('', options)).toBe('');
|
||||
expect(getMinutesDescription(' ', options)).toBe('');
|
||||
expect(getMinutesDescription('invalid', options)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { getMonthsDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription';
|
||||
import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
|
||||
|
||||
describe('getMonthsDescription', () => {
|
||||
const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
|
||||
|
||||
it('should handle wildcard (every month)', () => {
|
||||
expect(getMonthsDescription('*', options)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single months', () => {
|
||||
expect(getMonthsDescription('1', options)).toBe('only in January');
|
||||
expect(getMonthsDescription('6', options)).toBe('only in June');
|
||||
expect(getMonthsDescription('12', options)).toBe('only in December');
|
||||
});
|
||||
|
||||
it('should handle step values', () => {
|
||||
expect(getMonthsDescription('*/3', options)).toBe('every 3 months');
|
||||
expect(getMonthsDescription('*/6', options)).toBe('every 6 months');
|
||||
expect(getMonthsDescription('*/1', options)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle range with step', () => {
|
||||
expect(getMonthsDescription('1-6/2', options)).toBe(
|
||||
'every 2 months, between January and June',
|
||||
);
|
||||
expect(getMonthsDescription('3-9/3', options)).toBe(
|
||||
'every 3 months, between March and September',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ranges', () => {
|
||||
expect(getMonthsDescription('1-6', options)).toBe(
|
||||
'between January and June',
|
||||
);
|
||||
expect(getMonthsDescription('6-8', options)).toBe(
|
||||
'between June and August',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle lists', () => {
|
||||
expect(getMonthsDescription('1,6', options)).toBe(
|
||||
'only in January and June',
|
||||
);
|
||||
expect(getMonthsDescription('1,6,12', options)).toBe(
|
||||
'only in January, June and December',
|
||||
);
|
||||
expect(getMonthsDescription('3,6,9,12', options)).toBe(
|
||||
'only in March, June, September and December',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle month start index options', () => {
|
||||
const optionsZeroIndex = { ...options, monthStartIndexZero: true };
|
||||
|
||||
// When monthStartIndexZero is true, 0=January, 1=February, etc.
|
||||
expect(getMonthsDescription('0', optionsZeroIndex)).toBe('only in January');
|
||||
expect(getMonthsDescription('11', optionsZeroIndex)).toBe(
|
||||
'only in December',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(getMonthsDescription('', options)).toBe('');
|
||||
expect(getMonthsDescription(' ', options)).toBe('');
|
||||
expect(getMonthsDescription('invalid', options)).toBe('invalid');
|
||||
});
|
||||
|
||||
describe('with locale catalog', () => {
|
||||
// Note: These tests would need a mock locale catalog to test properly
|
||||
// For now, we test that the function doesn't crash with locale
|
||||
it('should handle locale catalog without crashing', () => {
|
||||
// Create a proper mock locale with the required properties for date-fns
|
||||
const mockLocale = {
|
||||
localize: {
|
||||
month: () => 'MockMonth',
|
||||
},
|
||||
formatLong: {
|
||||
date: () => 'P',
|
||||
time: () => 'p',
|
||||
dateTime: () => 'Pp',
|
||||
},
|
||||
} as any;
|
||||
expect(() =>
|
||||
getMonthsDescription('1', options, mockLocale),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { parseCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression';
|
||||
|
||||
describe('parseCronExpression', () => {
|
||||
it('should parse 4-field cron expression', () => {
|
||||
const result = parseCronExpression('0 * * *');
|
||||
expect(result).toEqual({
|
||||
seconds: '0',
|
||||
minutes: '0',
|
||||
hours: '0',
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: '*',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse 5-field cron expression', () => {
|
||||
const result = parseCronExpression('30 14 * * 1');
|
||||
expect(result).toEqual({
|
||||
seconds: '0',
|
||||
minutes: '30',
|
||||
hours: '14',
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse 6-field cron expression with seconds', () => {
|
||||
const result = parseCronExpression('15 30 14 * * 1');
|
||||
expect(result).toEqual({
|
||||
seconds: '15',
|
||||
minutes: '30',
|
||||
hours: '14',
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid field count', () => {
|
||||
expect(() => parseCronExpression('* *')).toThrow(
|
||||
'Invalid cron expression format. Expected 4, 5, or 6 fields, got 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty expression', () => {
|
||||
expect(() => parseCronExpression('')).toThrow(
|
||||
'Cron expression is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid cron syntax', () => {
|
||||
expect(() => parseCronExpression('invalid cron expression')).toThrow(
|
||||
'Invalid cron expression',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex expressions with ranges and steps', () => {
|
||||
const result = parseCronExpression('*/15 9-17 1-15 1,6,12 1-5');
|
||||
expect(result).toEqual({
|
||||
seconds: '0',
|
||||
minutes: '*/15',
|
||||
hours: '9-17',
|
||||
dayOfMonth: '1-15',
|
||||
month: '1,6,12',
|
||||
dayOfWeek: '1-5',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { type Locale } from 'date-fns';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getDayOfMonthDescription } from './descriptors/getDayOfMonthDescription';
|
||||
import { getDayOfWeekDescription } from './descriptors/getDayOfWeekDescription';
|
||||
import { getHoursDescription } from './descriptors/getHoursDescription';
|
||||
import { getMinutesDescription } from './descriptors/getMinutesDescription';
|
||||
import { getMonthsDescription } from './descriptors/getMonthsDescription';
|
||||
import {
|
||||
type CronDescriptionOptions,
|
||||
DEFAULT_CRON_DESCRIPTION_OPTIONS,
|
||||
} from './types/cronDescriptionOptions';
|
||||
import { parseCronExpression } from './utils/parseCronExpression';
|
||||
|
||||
export const describeCronExpression = (
|
||||
expression: string,
|
||||
options: CronDescriptionOptions = DEFAULT_CRON_DESCRIPTION_OPTIONS,
|
||||
localeCatalog?: Locale,
|
||||
): string => {
|
||||
if (!isDefined(expression) || expression.trim() === '') {
|
||||
throw new Error('Cron expression is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = parseCronExpression(expression);
|
||||
const mergedOptions = { ...DEFAULT_CRON_DESCRIPTION_OPTIONS, ...options };
|
||||
|
||||
const descriptions: string[] = [];
|
||||
|
||||
// Smart priority: Use hours description when we have specific hours,
|
||||
// otherwise use minutes description for patterns like */5
|
||||
const hoursDescription = getHoursDescription(
|
||||
parts.hours,
|
||||
parts.minutes,
|
||||
mergedOptions,
|
||||
);
|
||||
const minutesDescription = getMinutesDescription(
|
||||
parts.minutes,
|
||||
mergedOptions,
|
||||
);
|
||||
|
||||
// Smart logic for time descriptions
|
||||
if (parts.minutes.includes('/') && parts.hours.includes('-')) {
|
||||
// Special case: minute intervals with hour ranges (e.g., "*/15 9-17")
|
||||
if (isDefined(minutesDescription) && minutesDescription !== '') {
|
||||
descriptions.push(minutesDescription);
|
||||
}
|
||||
if (isDefined(hoursDescription) && hoursDescription !== '') {
|
||||
// Remove "at" prefix from hours description when combining
|
||||
const cleanHoursDesc = hoursDescription.replace(/^at\s+/, '');
|
||||
descriptions.push(cleanHoursDesc);
|
||||
}
|
||||
} else if (
|
||||
parts.hours === '*' &&
|
||||
(parts.minutes.includes('/') ||
|
||||
parts.minutes.includes('-') ||
|
||||
parts.minutes === '*')
|
||||
) {
|
||||
// Pattern like "*/5 * * * *", "15-45 * * * *", or "* * * * *" - prioritize minutes
|
||||
if (isDefined(minutesDescription) && minutesDescription !== '') {
|
||||
descriptions.push(minutesDescription);
|
||||
}
|
||||
} else if (parts.hours === '*' && parts.minutes === '0') {
|
||||
// Pattern like "0 * * * *" - should be "every hour", not "at the top of the hour"
|
||||
descriptions.push(t`every hour`);
|
||||
} else if (isDefined(hoursDescription) && hoursDescription !== '') {
|
||||
// Use hours description for specific hours or hour patterns
|
||||
descriptions.push(hoursDescription);
|
||||
} else if (isDefined(minutesDescription) && minutesDescription !== '') {
|
||||
// Fallback to minutes description
|
||||
descriptions.push(minutesDescription);
|
||||
}
|
||||
const dayOfMonthDesc = getDayOfMonthDescription(
|
||||
parts.dayOfMonth,
|
||||
mergedOptions,
|
||||
);
|
||||
const dayOfWeekDesc = getDayOfWeekDescription(
|
||||
parts.dayOfWeek,
|
||||
mergedOptions,
|
||||
localeCatalog,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(dayOfMonthDesc) &&
|
||||
dayOfMonthDesc !== '' &&
|
||||
parts.dayOfMonth !== '*'
|
||||
) {
|
||||
descriptions.push(dayOfMonthDesc);
|
||||
} else if (
|
||||
isDefined(dayOfWeekDesc) &&
|
||||
dayOfWeekDesc !== '' &&
|
||||
parts.dayOfWeek !== '*'
|
||||
) {
|
||||
descriptions.push(dayOfWeekDesc);
|
||||
}
|
||||
const monthsDesc = getMonthsDescription(
|
||||
parts.month,
|
||||
mergedOptions,
|
||||
localeCatalog,
|
||||
);
|
||||
if (isDefined(monthsDesc) && monthsDesc !== '') {
|
||||
descriptions.push(monthsDesc);
|
||||
}
|
||||
|
||||
if (descriptions.length === 0) {
|
||||
return t`every minute`;
|
||||
}
|
||||
// Simple joining - just use spaces, no commas for cleaner descriptions
|
||||
return descriptions.join(' ');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Failed to describe cron expression: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getOrdinalNumber } from '~/utils/format/getOrdinalNumber';
|
||||
import { isListValue } from '~/utils/validation/isListValue';
|
||||
import { isNumericRange } from '~/utils/validation/isNumericRange';
|
||||
import { isStepValue } from '~/utils/validation/isStepValue';
|
||||
import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
|
||||
|
||||
export const getDayOfMonthDescription = (
|
||||
dayOfMonth: string,
|
||||
_options: CronDescriptionOptions,
|
||||
): string => {
|
||||
if (!isDefined(dayOfMonth) || dayOfMonth.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Every day
|
||||
if (dayOfMonth === '*') {
|
||||
return t`every day`;
|
||||
}
|
||||
|
||||
// Last day of month
|
||||
if (dayOfMonth === 'L') {
|
||||
return t`on the last day of the month`;
|
||||
}
|
||||
|
||||
// Weekday (W) - closest weekday to the given date
|
||||
if (dayOfMonth.includes('W')) {
|
||||
const day = dayOfMonth.replace('W', '');
|
||||
const dayNum = parseInt(day, 10);
|
||||
if (!isNaN(dayNum)) {
|
||||
const ordinalDay = getOrdinalNumber(dayNum);
|
||||
return t`on the weekday closest to the ${ordinalDay} of the month`;
|
||||
}
|
||||
return t`on weekdays only`;
|
||||
}
|
||||
|
||||
// Step values (e.g., "*/5" = every 5 days)
|
||||
if (isStepValue(dayOfMonth)) {
|
||||
const [range, step] = dayOfMonth.split('/');
|
||||
const stepNum = parseInt(step, 10);
|
||||
|
||||
if (range === '*') {
|
||||
if (stepNum === 1) {
|
||||
return t`every day`;
|
||||
}
|
||||
const stepNumStr = stepNum.toString();
|
||||
return t`every ${stepNumStr} days`;
|
||||
}
|
||||
|
||||
// Range with step (e.g., "1-15/3")
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-');
|
||||
const stepNumStr = stepNum.toString();
|
||||
const startOrdinal = getOrdinalNumber(parseInt(start, 10));
|
||||
const endOrdinal = getOrdinalNumber(parseInt(end, 10));
|
||||
return t`every ${stepNumStr} days, between the ${startOrdinal} and ${endOrdinal} of the month`;
|
||||
}
|
||||
|
||||
const stepNumStr = stepNum.toString();
|
||||
return t`every ${stepNumStr} days`;
|
||||
}
|
||||
|
||||
// Range values (e.g., "1-15")
|
||||
if (isNumericRange(dayOfMonth) && dayOfMonth.includes('-')) {
|
||||
const [start, end] = dayOfMonth.split('-');
|
||||
const startNum = parseInt(start, 10);
|
||||
const endNum = parseInt(end, 10);
|
||||
const startOrdinal = getOrdinalNumber(startNum);
|
||||
const endOrdinal = getOrdinalNumber(endNum);
|
||||
return t`between the ${startOrdinal} and ${endOrdinal} of the month`;
|
||||
}
|
||||
|
||||
// List values (e.g., "1,15,30")
|
||||
if (isListValue(dayOfMonth)) {
|
||||
const values = dayOfMonth.split(',').map((v) => v.trim());
|
||||
const ordinalDays = values.map((day) => {
|
||||
const dayNum = parseInt(day, 10);
|
||||
return !isNaN(dayNum) ? getOrdinalNumber(dayNum) : day;
|
||||
});
|
||||
|
||||
if (ordinalDays.length === 2) {
|
||||
const firstDay = ordinalDays[0];
|
||||
const secondDay = ordinalDays[1];
|
||||
return t`on the ${firstDay} and ${secondDay} of the month`;
|
||||
}
|
||||
const lastDay = ordinalDays.pop();
|
||||
const remainingDays = ordinalDays.join(', ');
|
||||
return t`on the ${remainingDays} and ${lastDay} of the month`;
|
||||
}
|
||||
|
||||
// Single day value
|
||||
const dayNum = parseInt(dayOfMonth, 10);
|
||||
if (!isNaN(dayNum)) {
|
||||
const ordinalDay = getOrdinalNumber(dayNum);
|
||||
return t`on the ${ordinalDay} of the month`;
|
||||
}
|
||||
|
||||
return dayOfMonth;
|
||||
};
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { format, type Locale } from 'date-fns';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isListValue } from '~/utils/validation/isListValue';
|
||||
import { isNumericRange } from '~/utils/validation/isNumericRange';
|
||||
import { isStepValue } from '~/utils/validation/isStepValue';
|
||||
import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
|
||||
|
||||
const getDayName = (
|
||||
dayNum: number,
|
||||
dayOfWeekStartIndexZero: boolean,
|
||||
localeCatalog?: Locale,
|
||||
): string => {
|
||||
// Handle both 0 and 7 as Sunday
|
||||
const normalizedDay = dayNum === 7 ? 0 : dayNum;
|
||||
|
||||
// Create a date for the given day (using a Sunday as base: 2024-01-07)
|
||||
const baseDate = new Date(2024, 0, 7); // Sunday
|
||||
const dayDate = new Date(
|
||||
baseDate.getTime() + normalizedDay * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
if (isDefined(localeCatalog)) {
|
||||
return format(dayDate, 'EEEE', { locale: localeCatalog });
|
||||
}
|
||||
|
||||
return format(dayDate, 'EEEE');
|
||||
};
|
||||
|
||||
export const getDayOfWeekDescription = (
|
||||
dayOfWeek: string,
|
||||
options: CronDescriptionOptions,
|
||||
localeCatalog?: Locale,
|
||||
): string => {
|
||||
if (!isDefined(dayOfWeek) || dayOfWeek.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Every day of week
|
||||
if (dayOfWeek === '*') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const dayOfWeekStartIndexZero = options.dayOfWeekStartIndexZero ?? true;
|
||||
|
||||
// Last occurrence of a weekday in the month (e.g., "5L" = last Friday)
|
||||
if (dayOfWeek.includes('L')) {
|
||||
const day = dayOfWeek.replace('L', '');
|
||||
const dayNum = parseInt(day, 10);
|
||||
if (!isNaN(dayNum)) {
|
||||
const dayName = getDayName(
|
||||
dayNum,
|
||||
dayOfWeekStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
return t`on the last ${dayName} of the month`;
|
||||
}
|
||||
}
|
||||
|
||||
// Nth occurrence of a weekday (e.g., "1#2" = second Monday)
|
||||
if (dayOfWeek.includes('#')) {
|
||||
const [day, occurrence] = dayOfWeek.split('#');
|
||||
const dayNum = parseInt(day, 10);
|
||||
const occurrenceNum = parseInt(occurrence, 10);
|
||||
|
||||
if (!isNaN(dayNum) && !isNaN(occurrenceNum)) {
|
||||
const dayName = getDayName(
|
||||
dayNum,
|
||||
dayOfWeekStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
const getOrdinals = () => [
|
||||
t`first`,
|
||||
t`second`,
|
||||
t`third`,
|
||||
t`fourth`,
|
||||
t`fifth`,
|
||||
];
|
||||
const ordinals = getOrdinals();
|
||||
const ordinal = ordinals[occurrenceNum - 1] || occurrenceNum.toString();
|
||||
return t`on the ${ordinal} ${dayName} of the month`;
|
||||
}
|
||||
}
|
||||
|
||||
// Step values (e.g., "*/2" = every other day)
|
||||
if (isStepValue(dayOfWeek)) {
|
||||
const [range, step] = dayOfWeek.split('/');
|
||||
const stepNum = parseInt(step, 10);
|
||||
|
||||
if (range === '*') {
|
||||
if (stepNum === 1) {
|
||||
return t`every day`;
|
||||
}
|
||||
const stepNumStr = stepNum.toString();
|
||||
return t`every ${stepNumStr} days`;
|
||||
}
|
||||
|
||||
return t`every ${stepNum} days`;
|
||||
}
|
||||
|
||||
// Range values (e.g., "1-5" = Monday to Friday)
|
||||
if (isNumericRange(dayOfWeek) && dayOfWeek.includes('-')) {
|
||||
const [start, end] = dayOfWeek.split('-');
|
||||
const startDay = getDayName(
|
||||
parseInt(start, 10),
|
||||
dayOfWeekStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
const endDay = getDayName(
|
||||
parseInt(end, 10),
|
||||
dayOfWeekStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
|
||||
// Special case for weekdays
|
||||
if (start === '1' && end === '5' && dayOfWeekStartIndexZero) {
|
||||
return t`on weekdays`;
|
||||
}
|
||||
if (start === '2' && end === '6' && !dayOfWeekStartIndexZero) {
|
||||
return t`on weekdays`;
|
||||
}
|
||||
|
||||
return t`from ${startDay} to ${endDay}`;
|
||||
}
|
||||
|
||||
// List values (e.g., "1,3,5")
|
||||
if (isListValue(dayOfWeek)) {
|
||||
const values = dayOfWeek.split(',').map((v) => v.trim());
|
||||
const dayNames = values.map((day) => {
|
||||
const dayNum = parseInt(day, 10);
|
||||
return !isNaN(dayNum)
|
||||
? getDayName(dayNum, dayOfWeekStartIndexZero, localeCatalog)
|
||||
: day;
|
||||
});
|
||||
|
||||
if (dayNames.length === 1) {
|
||||
const dayName = dayNames[0];
|
||||
return t`only on ${dayName}`;
|
||||
}
|
||||
if (dayNames.length === 2) {
|
||||
const firstDay = dayNames[0];
|
||||
const secondDay = dayNames[1];
|
||||
return t`only on ${firstDay} and ${secondDay}`;
|
||||
}
|
||||
const lastDay = dayNames.pop();
|
||||
const remainingDays = dayNames.join(', ');
|
||||
return t`only on ${remainingDays} and ${lastDay}`;
|
||||
}
|
||||
|
||||
// Single day value
|
||||
const dayNum = parseInt(dayOfWeek, 10);
|
||||
if (!isNaN(dayNum)) {
|
||||
const dayName = getDayName(dayNum, dayOfWeekStartIndexZero, localeCatalog);
|
||||
return t`only on ${dayName}`;
|
||||
}
|
||||
|
||||
return dayOfWeek;
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { formatTime as formatCronTime } from '~/utils/format/formatTime';
|
||||
import { isListValue } from '~/utils/validation/isListValue';
|
||||
import { isNumericRange } from '~/utils/validation/isNumericRange';
|
||||
import { isStepValue } from '~/utils/validation/isStepValue';
|
||||
import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
|
||||
|
||||
export const getHoursDescription = (
|
||||
hours: string,
|
||||
minutes: string,
|
||||
options: CronDescriptionOptions,
|
||||
): string => {
|
||||
if (!isDefined(hours) || hours.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const use24Hour = options.use24HourTimeFormat ?? true;
|
||||
|
||||
if (hours === '*') {
|
||||
return t`every hour`;
|
||||
}
|
||||
|
||||
if (isStepValue(hours)) {
|
||||
const [range, step] = hours.split('/');
|
||||
const stepNum = parseInt(step, 10);
|
||||
|
||||
if (range === '*') {
|
||||
if (stepNum === 1) {
|
||||
return t`every hour`;
|
||||
}
|
||||
const stepNumStr = stepNum.toString();
|
||||
return t`every ${stepNumStr} hours`;
|
||||
}
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-');
|
||||
const stepNumStr = stepNum.toString();
|
||||
const startTime = formatCronTime(start, '0', use24Hour);
|
||||
const endTime = formatCronTime(end, '0', use24Hour);
|
||||
return t`every ${stepNumStr} hours, between ${startTime} and ${endTime}`;
|
||||
}
|
||||
|
||||
const stepNumStr = stepNum.toString();
|
||||
return t`every ${stepNumStr} hours`;
|
||||
}
|
||||
|
||||
if (isNumericRange(hours) && hours.includes('-')) {
|
||||
const [start, end] = hours.split('-');
|
||||
const startTime = formatCronTime(start, '0', use24Hour);
|
||||
const endTime = formatCronTime(end, '0', use24Hour);
|
||||
return t`between ${startTime} and ${endTime}`;
|
||||
}
|
||||
|
||||
if (isListValue(hours)) {
|
||||
const values = hours.split(',').map((v) => v.trim());
|
||||
const formattedTimes = values.map((hour) =>
|
||||
formatCronTime(hour, minutes || '0', use24Hour),
|
||||
);
|
||||
|
||||
if (formattedTimes.length === 2) {
|
||||
const firstTime = formattedTimes[0];
|
||||
const secondTime = formattedTimes[1];
|
||||
return t`at ${firstTime} and ${secondTime}`;
|
||||
}
|
||||
const lastTime = formattedTimes.pop();
|
||||
const remainingTimes = formattedTimes.join(', ');
|
||||
return t`at ${remainingTimes} and ${lastTime}`;
|
||||
}
|
||||
|
||||
const hourNum = parseInt(hours, 10);
|
||||
if (!isNaN(hourNum)) {
|
||||
const formattedTime = formatCronTime(hours, minutes || '0', use24Hour);
|
||||
return t`at ${formattedTime}`;
|
||||
}
|
||||
|
||||
return hours;
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isListValue } from '~/utils/validation/isListValue';
|
||||
import { isNumericRange } from '~/utils/validation/isNumericRange';
|
||||
import { isStepValue } from '~/utils/validation/isStepValue';
|
||||
import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
|
||||
|
||||
export const getMinutesDescription = (
|
||||
minutes: string,
|
||||
_options: CronDescriptionOptions,
|
||||
): string => {
|
||||
if (!isDefined(minutes) || minutes.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (minutes === '*') {
|
||||
return t`every minute`;
|
||||
}
|
||||
|
||||
if (isStepValue(minutes)) {
|
||||
const [range, step] = minutes.split('/');
|
||||
const stepNum = parseInt(step, 10);
|
||||
const stepNumStr = stepNum.toString();
|
||||
|
||||
if (range === '*') {
|
||||
if (stepNum === 1) {
|
||||
return t`every minute`;
|
||||
}
|
||||
return t`every ${stepNumStr} minutes`;
|
||||
}
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-');
|
||||
return t`every ${stepNumStr} minutes, between minute ${start} and ${end}`;
|
||||
}
|
||||
|
||||
return t`every ${stepNumStr} minutes`;
|
||||
}
|
||||
|
||||
if (isNumericRange(minutes) && minutes.includes('-')) {
|
||||
const [start, end] = minutes.split('-');
|
||||
return t`between minute ${start} and ${end}`;
|
||||
}
|
||||
|
||||
if (isListValue(minutes)) {
|
||||
const values = minutes.split(',').map((v) => v.trim());
|
||||
if (values.length === 2) {
|
||||
const firstValue = values[0];
|
||||
const secondValue = values[1];
|
||||
return t`at minutes ${firstValue} and ${secondValue}`;
|
||||
}
|
||||
const lastValue = values.pop();
|
||||
const remainingValues = values.join(', ');
|
||||
return t`at minutes ${remainingValues} and ${lastValue}`;
|
||||
}
|
||||
|
||||
const minuteNum = parseInt(minutes, 10);
|
||||
if (!isNaN(minuteNum)) {
|
||||
if (minuteNum === 0) {
|
||||
return t`at the top of the hour`;
|
||||
}
|
||||
if (minuteNum === 1) {
|
||||
return t`at 1 minute past the hour`;
|
||||
}
|
||||
const minuteNumStr = minuteNum.toString();
|
||||
return t`at ${minuteNumStr} minutes past the hour`;
|
||||
}
|
||||
|
||||
return minutes;
|
||||
};
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { format, type Locale } from 'date-fns';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isListValue } from '~/utils/validation/isListValue';
|
||||
import { isNumericRange } from '~/utils/validation/isNumericRange';
|
||||
import { isStepValue } from '~/utils/validation/isStepValue';
|
||||
import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
|
||||
|
||||
const getMonthName = (
|
||||
monthNum: number,
|
||||
monthStartIndexZero: boolean,
|
||||
localeCatalog?: Locale,
|
||||
): string => {
|
||||
const index = monthStartIndexZero ? monthNum : monthNum - 1;
|
||||
|
||||
// Create a date for the given month (using January 1st as base)
|
||||
const monthDate = new Date(2024, index, 1);
|
||||
|
||||
if (isDefined(localeCatalog)) {
|
||||
return format(monthDate, 'MMMM', { locale: localeCatalog });
|
||||
}
|
||||
|
||||
return format(monthDate, 'MMMM');
|
||||
};
|
||||
|
||||
export const getMonthsDescription = (
|
||||
months: string,
|
||||
options: CronDescriptionOptions,
|
||||
localeCatalog?: Locale,
|
||||
): string => {
|
||||
if (!isDefined(months) || months.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Every month
|
||||
if (months === '*') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const monthStartIndexZero = options.monthStartIndexZero ?? false;
|
||||
|
||||
// Step values (e.g., "*/3" = every 3 months)
|
||||
if (isStepValue(months)) {
|
||||
const [range, step] = months.split('/');
|
||||
const stepNum = parseInt(step, 10);
|
||||
const stepNumStr = stepNum.toString();
|
||||
|
||||
if (range === '*') {
|
||||
if (stepNum === 1) {
|
||||
return '';
|
||||
}
|
||||
return t`every ${stepNumStr} months`;
|
||||
}
|
||||
|
||||
// Range with step (e.g., "1-6/2")
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-');
|
||||
const startMonth = getMonthName(
|
||||
parseInt(start, 10),
|
||||
monthStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
const endMonth = getMonthName(
|
||||
parseInt(end, 10),
|
||||
monthStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
return t`every ${stepNumStr} months, between ${startMonth} and ${endMonth}`;
|
||||
}
|
||||
|
||||
return t`every ${stepNumStr} months`;
|
||||
}
|
||||
|
||||
// Range values (e.g., "1-6")
|
||||
if (isNumericRange(months) && months.includes('-')) {
|
||||
const [start, end] = months.split('-');
|
||||
const startMonth = getMonthName(
|
||||
parseInt(start, 10),
|
||||
monthStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
const endMonth = getMonthName(
|
||||
parseInt(end, 10),
|
||||
monthStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
return t`between ${startMonth} and ${endMonth}`;
|
||||
}
|
||||
|
||||
// List values (e.g., "1,6,12")
|
||||
if (isListValue(months)) {
|
||||
const values = months.split(',').map((v) => v.trim());
|
||||
const monthNames = values.map((month) => {
|
||||
const monthNum = parseInt(month, 10);
|
||||
return !isNaN(monthNum)
|
||||
? getMonthName(monthNum, monthStartIndexZero, localeCatalog)
|
||||
: month;
|
||||
});
|
||||
|
||||
if (monthNames.length === 1) {
|
||||
const monthName = monthNames[0];
|
||||
return t`only in ${monthName}`;
|
||||
}
|
||||
if (monthNames.length === 2) {
|
||||
const firstMonth = monthNames[0];
|
||||
const secondMonth = monthNames[1];
|
||||
return t`only in ${firstMonth} and ${secondMonth}`;
|
||||
}
|
||||
const lastMonth = monthNames.pop();
|
||||
const remainingMonths = monthNames.join(', ');
|
||||
return t`only in ${remainingMonths} and ${lastMonth}`;
|
||||
}
|
||||
|
||||
// Single month value
|
||||
const monthNum = parseInt(months, 10);
|
||||
if (!isNaN(monthNum)) {
|
||||
const monthName = getMonthName(
|
||||
monthNum,
|
||||
monthStartIndexZero,
|
||||
localeCatalog,
|
||||
);
|
||||
return t`only in ${monthName}`;
|
||||
}
|
||||
|
||||
return months;
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export type CronDescriptionOptions = {
|
||||
verbose?: boolean;
|
||||
use24HourTimeFormat?: boolean;
|
||||
dayOfWeekStartIndexZero?: boolean;
|
||||
monthStartIndexZero?: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_CRON_DESCRIPTION_OPTIONS: CronDescriptionOptions = {
|
||||
verbose: false,
|
||||
use24HourTimeFormat: true, // Twenty uses 24-hour format by default
|
||||
dayOfWeekStartIndexZero: true,
|
||||
monthStartIndexZero: false,
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export type CronExpressionParts = {
|
||||
seconds: string;
|
||||
minutes: string;
|
||||
hours: string;
|
||||
dayOfMonth: string;
|
||||
month: string;
|
||||
dayOfWeek: string;
|
||||
year?: string;
|
||||
};
|
||||
|
||||
export type CronFieldType =
|
||||
| 'seconds'
|
||||
| 'minutes'
|
||||
| 'hours'
|
||||
| 'dayOfMonth'
|
||||
| 'month'
|
||||
| 'dayOfWeek'
|
||||
| 'year';
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { type CronExpressionParts } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const parseCronExpression = (
|
||||
expression: string,
|
||||
): CronExpressionParts => {
|
||||
if (!isDefined(expression) || expression.trim() === '') {
|
||||
throw new Error('Cron expression is required');
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
|
||||
if (parts.length < 4 || parts.length > 6) {
|
||||
throw new Error(
|
||||
`Invalid cron expression format. Expected 4, 5, or 6 fields, got ${parts.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
CronExpressionParser.parse(expression, { tz: 'UTC' });
|
||||
|
||||
// Handle different cron formats that cron-parser accepts
|
||||
if (parts.length === 4) {
|
||||
// Reduced format: hour day month dayOfWeek (minute defaults to 0)
|
||||
return {
|
||||
seconds: '0',
|
||||
minutes: '0',
|
||||
hours: parts[0],
|
||||
dayOfMonth: parts[1],
|
||||
month: parts[2],
|
||||
dayOfWeek: parts[3],
|
||||
};
|
||||
} else if (parts.length === 5) {
|
||||
// Standard format: minute hour day month dayOfWeek
|
||||
return {
|
||||
seconds: '0',
|
||||
minutes: parts[0],
|
||||
hours: parts[1],
|
||||
dayOfMonth: parts[2],
|
||||
month: parts[3],
|
||||
dayOfWeek: parts[4],
|
||||
};
|
||||
} else if (parts.length === 6) {
|
||||
// Extended format: second minute hour day month dayOfWeek
|
||||
return {
|
||||
seconds: parts[0],
|
||||
minutes: parts[1],
|
||||
hours: parts[2],
|
||||
dayOfMonth: parts[3],
|
||||
month: parts[4],
|
||||
dayOfWeek: parts[5],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Unexpected error in cron expression parsing');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new Error(`Invalid cron expression: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type CronFieldType } from '../types/cronExpressionParts';
|
||||
|
||||
type FieldRange = {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
const FIELD_RANGES: Record<CronFieldType, FieldRange> = {
|
||||
seconds: { min: 0, max: 59 },
|
||||
minutes: { min: 0, max: 59 },
|
||||
hours: { min: 0, max: 23 },
|
||||
dayOfMonth: { min: 1, max: 31 },
|
||||
month: { min: 1, max: 12 },
|
||||
dayOfWeek: { min: 0, max: 7 }, // 0 and 7 both represent Sunday
|
||||
year: { min: 1970, max: 2099 },
|
||||
};
|
||||
|
||||
export const validateCronField = (
|
||||
value: string,
|
||||
fieldType: CronFieldType,
|
||||
): boolean => {
|
||||
if (!isDefined(value) || value.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const range = FIELD_RANGES[fieldType];
|
||||
if (!isDefined(range)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.includes('/')) {
|
||||
const [rangePart, stepPart] = value.split('/');
|
||||
const step = parseInt(stepPart, 10);
|
||||
|
||||
if (isNaN(step) || step <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rangePart === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validateCronField(rangePart, fieldType);
|
||||
}
|
||||
|
||||
if (value.includes('-')) {
|
||||
const [startStr, endStr] = value.split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
|
||||
if (isNaN(start) || isNaN(end)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
start >= range.min &&
|
||||
start <= range.max &&
|
||||
end >= range.min &&
|
||||
end <= range.max &&
|
||||
start <= end
|
||||
);
|
||||
}
|
||||
|
||||
if (value.includes(',')) {
|
||||
const values = value.split(',');
|
||||
return values.every((val) => validateCronField(val.trim(), fieldType));
|
||||
}
|
||||
|
||||
const numValue = parseInt(value, 10);
|
||||
if (isNaN(numValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fieldType === 'dayOfWeek' && (numValue === 0 || numValue === 7)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return numValue >= range.min && numValue <= range.max;
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { formatTime } from '../formatTime';
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('should format 24-hour time', () => {
|
||||
expect(formatTime('9', '30', true)).toBe('09:30');
|
||||
expect(formatTime('14', '0', true)).toBe('14:00');
|
||||
expect(formatTime('0', '0', true)).toBe('00:00');
|
||||
expect(formatTime('23', '59', true)).toBe('23:59');
|
||||
});
|
||||
|
||||
it('should format 12-hour time', () => {
|
||||
expect(formatTime('9', '30', false)).toBe('9:30 AM');
|
||||
expect(formatTime('14', '0', false)).toBe('2:00 PM');
|
||||
expect(formatTime('0', '0', false)).toBe('12:00 AM');
|
||||
expect(formatTime('12', '0', false)).toBe('12:00 PM');
|
||||
expect(formatTime('23', '59', false)).toBe('11:59 PM');
|
||||
});
|
||||
|
||||
it('should handle invalid inputs', () => {
|
||||
expect(formatTime('invalid', '30', true)).toBe('');
|
||||
expect(formatTime('9', 'invalid', true)).toBe('');
|
||||
expect(formatTime('', '', true)).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { getOrdinalNumber } from '../getOrdinalNumber';
|
||||
|
||||
describe('getOrdinalNumber', () => {
|
||||
it('should return correct ordinal numbers', () => {
|
||||
expect(getOrdinalNumber(1)).toBe('1st');
|
||||
expect(getOrdinalNumber(2)).toBe('2nd');
|
||||
expect(getOrdinalNumber(3)).toBe('3rd');
|
||||
expect(getOrdinalNumber(4)).toBe('4th');
|
||||
expect(getOrdinalNumber(11)).toBe('11th');
|
||||
expect(getOrdinalNumber(21)).toBe('21st');
|
||||
expect(getOrdinalNumber(22)).toBe('22nd');
|
||||
expect(getOrdinalNumber(23)).toBe('23rd');
|
||||
});
|
||||
});
|
||||
27
packages/twenty-front/src/utils/format/formatTime.ts
Normal file
27
packages/twenty-front/src/utils/format/formatTime.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const formatTime = (
|
||||
hour: string,
|
||||
minute: string,
|
||||
use24HourFormat: boolean,
|
||||
): string => {
|
||||
if (!isDefined(hour) || !isDefined(minute)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const hourNum = parseInt(hour, 10);
|
||||
const minuteNum = parseInt(minute, 10);
|
||||
|
||||
if (isNaN(hourNum) || isNaN(minuteNum)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (use24HourFormat) {
|
||||
return `${hourNum.toString().padStart(2, '0')}:${minuteNum.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
const period = hourNum >= 12 ? 'PM' : 'AM';
|
||||
const displayHour =
|
||||
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
return `${displayHour}:${minuteNum.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
};
|
||||
31
packages/twenty-front/src/utils/format/getOrdinalNumber.ts
Normal file
31
packages/twenty-front/src/utils/format/getOrdinalNumber.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { selectOrdinal } from '@lingui/core/macro';
|
||||
|
||||
export const getOrdinalNumber = (num: number): string => {
|
||||
try {
|
||||
return selectOrdinal(num, {
|
||||
one: `${num}st`,
|
||||
two: `${num}nd`,
|
||||
few: `${num}rd`,
|
||||
other: `${num}th`,
|
||||
});
|
||||
} catch {
|
||||
// Fallback for test environment - basic English ordinals
|
||||
const lastDigit = num % 10;
|
||||
const lastTwoDigits = num % 100;
|
||||
|
||||
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) {
|
||||
return `${num}th`;
|
||||
}
|
||||
|
||||
switch (lastDigit) {
|
||||
case 1:
|
||||
return `${num}st`;
|
||||
case 2:
|
||||
return `${num}nd`;
|
||||
case 3:
|
||||
return `${num}rd`;
|
||||
default:
|
||||
return `${num}th`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { isListValue } from '../isListValue';
|
||||
|
||||
describe('isListValue', () => {
|
||||
it('should detect list values', () => {
|
||||
expect(isListValue('1,2,3')).toBe(true);
|
||||
expect(isListValue('0,15,30,45')).toBe(true);
|
||||
expect(isListValue('1,5')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-list values', () => {
|
||||
expect(isListValue('*')).toBe(false);
|
||||
expect(isListValue('*/5')).toBe(false);
|
||||
expect(isListValue('1-5')).toBe(false);
|
||||
expect(isListValue('15')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { isNumericRange } from '../isNumericRange';
|
||||
|
||||
describe('isNumericRange', () => {
|
||||
it('should detect numeric ranges', () => {
|
||||
expect(isNumericRange('1-5')).toBe(true);
|
||||
expect(isNumericRange('10-20')).toBe(true);
|
||||
expect(isNumericRange('0-59')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect single numbers', () => {
|
||||
expect(isNumericRange('15')).toBe(true);
|
||||
expect(isNumericRange('0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-numeric ranges', () => {
|
||||
expect(isNumericRange('*')).toBe(false);
|
||||
expect(isNumericRange('*/5')).toBe(false);
|
||||
expect(isNumericRange('1,2,3')).toBe(false);
|
||||
expect(isNumericRange('invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { isStepValue } from '../isStepValue';
|
||||
|
||||
describe('isStepValue', () => {
|
||||
it('should detect step values', () => {
|
||||
expect(isStepValue('*/5')).toBe(true);
|
||||
expect(isStepValue('1-10/2')).toBe(true);
|
||||
expect(isStepValue('0-59/15')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-step values', () => {
|
||||
expect(isStepValue('*')).toBe(false);
|
||||
expect(isStepValue('1-5')).toBe(false);
|
||||
expect(isStepValue('1,2,3')).toBe(false);
|
||||
expect(isStepValue('15')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isListValue = (value: string): boolean => {
|
||||
return isDefined(value) && value.includes(',');
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isNumericRange = (value: string): boolean => {
|
||||
return isDefined(value) && /^\d+(-\d+)?$/.test(value);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isStepValue = (value: string): boolean => {
|
||||
return isDefined(value) && value.includes('/');
|
||||
};
|
||||
|
|
@ -96,7 +96,6 @@
|
|||
"cloudflare": "^4.5.0",
|
||||
"connect-redis": "^7.1.1",
|
||||
"cron-parser": "5.1.1",
|
||||
"cron-validate": "1.4.5",
|
||||
"dataloader": "2.2.2",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-equal": "2.2.3",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { WorkflowTriggerException } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
|
||||
import {
|
||||
type WorkflowCronTrigger,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
import { WorkflowTriggerException } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
|
||||
import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule';
|
||||
|
||||
describe('computeCronPatternFromSchedule', () => {
|
||||
|
|
@ -20,7 +20,7 @@ describe('computeCronPatternFromSchedule', () => {
|
|||
expect(computeCronPatternFromSchedule(trigger)).toBe('12 * * * *');
|
||||
});
|
||||
|
||||
it('should throw an exception for unsupported pattern for CUSTOM type', () => {
|
||||
it('should support 6-field cron patterns with seconds for CUSTOM type', () => {
|
||||
const trigger: WorkflowCronTrigger = {
|
||||
name: '',
|
||||
type: WorkflowTriggerType.CRON,
|
||||
|
|
@ -31,11 +31,25 @@ describe('computeCronPatternFromSchedule', () => {
|
|||
},
|
||||
};
|
||||
|
||||
expect(computeCronPatternFromSchedule(trigger)).toBe('0 12 * * * *');
|
||||
});
|
||||
|
||||
it('should throw an exception for invalid pattern for CUSTOM type', () => {
|
||||
const trigger: WorkflowCronTrigger = {
|
||||
name: '',
|
||||
type: WorkflowTriggerType.CRON,
|
||||
settings: {
|
||||
type: 'CUSTOM',
|
||||
pattern: '60 25 32 13 8',
|
||||
outputSchema: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
|
||||
WorkflowTriggerException,
|
||||
);
|
||||
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
|
||||
"Cron pattern '0 12 * * * *' is invalid",
|
||||
"Cron pattern '60 25 32 13 8' is invalid",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import cron from 'cron-validate';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import {
|
||||
WorkflowTriggerException,
|
||||
|
|
@ -8,11 +8,11 @@ import {
|
|||
import { type WorkflowCronTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
const validatePattern = (pattern: string) => {
|
||||
const cronValidator = cron(pattern);
|
||||
|
||||
if (cronValidator.isError()) {
|
||||
try {
|
||||
CronExpressionParser.parse(pattern);
|
||||
} catch (error) {
|
||||
throw new WorkflowTriggerException(
|
||||
`Cron pattern '${pattern}' is invalid`,
|
||||
`Cron pattern '${pattern}' is invalid: ${error.message}`,
|
||||
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
|
||||
{
|
||||
userFriendlyMessage: t`Cron pattern '${pattern}' is invalid`,
|
||||
|
|
|
|||
32
yarn.lock
32
yarn.lock
|
|
@ -3465,7 +3465,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.5, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
|
||||
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
|
||||
version: 7.26.7
|
||||
resolution: "@babel/runtime@npm:7.26.7"
|
||||
dependencies:
|
||||
|
|
@ -21555,7 +21555,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/lodash@npm:*, @types/lodash@npm:^4.14.149, @types/lodash@npm:^4.14.165, @types/lodash@npm:^4.14.175":
|
||||
"@types/lodash@npm:*, @types/lodash@npm:^4.14.149, @types/lodash@npm:^4.14.175":
|
||||
version: 4.17.15
|
||||
resolution: "@types/lodash@npm:4.17.15"
|
||||
checksum: 10c0/2eb2dc6d231f5fb4603d176c08c8d7af688f574d09af47466a179cd7812d9f64144ba74bb32ca014570ffdc544eedc51b7a5657212bad083b6eecbd72223f9bb
|
||||
|
|
@ -28937,15 +28937,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cron-validate@npm:1.4.5":
|
||||
version: 1.4.5
|
||||
resolution: "cron-validate@npm:1.4.5"
|
||||
dependencies:
|
||||
yup: "npm:0.32.9"
|
||||
checksum: 10c0/4c210bea21832269fd8e18e4c9e9d750b314da04bc475b8b58414a6421a4c86804a6f1a8b9eda6d357af4c43f627fa3ee372f6fbb99b849d2a9b2aef2917bddf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cron@npm:2.4.3":
|
||||
version: 2.4.3
|
||||
resolution: "cron@npm:2.4.3"
|
||||
|
|
@ -39800,7 +39791,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash-es@npm:^4.17.15, lodash-es@npm:^4.17.21":
|
||||
"lodash-es@npm:^4.17.21":
|
||||
version: 4.17.21
|
||||
resolution: "lodash-es@npm:4.17.21"
|
||||
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
|
||||
|
|
@ -52460,6 +52451,7 @@ __metadata:
|
|||
apollo-link-rest: "npm:^0.9.0"
|
||||
apollo-upload-client: "npm:^17.0.0"
|
||||
buffer: "npm:^6.0.3"
|
||||
cron-parser: "npm:5.1.1"
|
||||
date-fns: "npm:^2.30.0"
|
||||
docx: "npm:^9.1.0"
|
||||
eslint: "npm:^9.32.0"
|
||||
|
|
@ -52637,7 +52629,6 @@ __metadata:
|
|||
cloudflare: "npm:^4.5.0"
|
||||
connect-redis: "npm:^7.1.1"
|
||||
cron-parser: "npm:5.1.1"
|
||||
cron-validate: "npm:1.4.5"
|
||||
dataloader: "npm:2.2.2"
|
||||
date-fns: "npm:2.30.0"
|
||||
deep-equal: "npm:2.2.3"
|
||||
|
|
@ -56073,21 +56064,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yup@npm:0.32.9":
|
||||
version: 0.32.9
|
||||
resolution: "yup@npm:0.32.9"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.10.5"
|
||||
"@types/lodash": "npm:^4.14.165"
|
||||
lodash: "npm:^4.17.20"
|
||||
lodash-es: "npm:^4.17.15"
|
||||
nanoclone: "npm:^0.2.1"
|
||||
property-expr: "npm:^2.0.4"
|
||||
toposort: "npm:^2.0.2"
|
||||
checksum: 10c0/b2adff31f4be85aaad338e6db12a26715b9e11270c587afe051d42c423f7f24de2d184f646047cb5c3b8c65163c37611f8309f2ef4eb6bb7a66688158a081d66
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yup@npm:^0.32.0":
|
||||
version: 0.32.11
|
||||
resolution: "yup@npm:0.32.11"
|
||||
|
|
|
|||
Loading…
Reference in a new issue