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:
Omar Eltomy 2025-09-24 20:45:56 +03:00 committed by GitHub
parent 37ce5c48bb
commit 2af20a4cf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2093 additions and 121 deletions

View file

@ -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",

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
</>

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});

View file

@ -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();
});
});
});

View file

@ -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',
});
});
});

View file

@ -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}`);
}
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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,
};

View file

@ -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';

View file

@ -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}`);
}
};

View file

@ -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;
};

View file

@ -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('');
});
});

View file

@ -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');
});
});

View 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}`;
}
};

View 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`;
}
}
};

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -0,0 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
export const isListValue = (value: string): boolean => {
return isDefined(value) && value.includes(',');
};

View file

@ -0,0 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
export const isNumericRange = (value: string): boolean => {
return isDefined(value) && /^\d+(-\d+)?$/.test(value);
};

View file

@ -0,0 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
export const isStepValue = (value: string): boolean => {
return isDefined(value) && value.includes('/');
};

View file

@ -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",

View file

@ -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",
);
});

View file

@ -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`,

View file

@ -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"