diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index d5b9073b566..de3ce74683b 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -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", diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx new file mode 100644 index 00000000000..5d6471076c1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx @@ -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 ( + + + {errorMessage || t`Please check your cron expression syntax.`} + + + ); + } + + const nextExecutions = getNextExecutions(cronExpression); + + return ( + + + {t`Schedule`} + + {customDescription} + + + {t`Schedule runs in UTC timezone.`} + + + + {nextExecutions.length > 0 && ( + + {t`Upcoming execution times (${timeZone})`} + {nextExecutions.slice(0, 3).map((execution, index) => ( + + {formatDateTimeString({ + value: execution.toISOString(), + timeZone, + dateFormat, + timeFormat, + localeCatalog: dateLocale.localeCatalog, + })} + + ))} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelperLazy.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelperLazy.tsx new file mode 100644 index 00000000000..eec2c9d2d79 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelperLazy.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx index a75ffaf4023..1b1aa5f28fb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx @@ -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' && ( - { - if (triggerOptions.readonly === true) { - return; - } + <> + { + 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, - }, - }); - }} - /> + }} + /> + + )} {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} /> 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} /> 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} /> + )} {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} /> 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} /> + )} {trigger.settings.type === 'MINUTES' && ( - { - if (triggerOptions.readonly === true) { - return; - } + <> + { + 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} + /> + + )} diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/cronstrueComparison.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/cronstrueComparison.test.ts new file mode 100644 index 00000000000..f38b11cc5b1 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/cronstrueComparison.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts new file mode 100644 index 00000000000..5c704c0161e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfMonthDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfMonthDescription.test.ts new file mode 100644 index 00000000000..6fd838c0fba --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfMonthDescription.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfWeekDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfWeekDescription.test.ts new file mode 100644 index 00000000000..ced84c68779 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfWeekDescription.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts new file mode 100644 index 00000000000..de61ab7e32b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMinutesDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMinutesDescription.test.ts new file mode 100644 index 00000000000..05ae1a2d0e8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMinutesDescription.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMonthsDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMonthsDescription.test.ts new file mode 100644 index 00000000000..1ae4233a19a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMonthsDescription.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts new file mode 100644 index 00000000000..b5faefb1a38 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts @@ -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', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression.ts new file mode 100644 index 00000000000..046f764ec20 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression.ts @@ -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}`); + } +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription.ts new file mode 100644 index 00000000000..dbe630ad97d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription.ts new file mode 100644 index 00000000000..503da30f368 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts new file mode 100644 index 00000000000..2119235efc4 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription.ts new file mode 100644 index 00000000000..5fa0253b317 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription.ts new file mode 100644 index 00000000000..5b25b7fa345 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions.ts new file mode 100644 index 00000000000..ab6f68b442d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions.ts @@ -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, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts.ts new file mode 100644 index 00000000000..8ee966cb0ee --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts new file mode 100644 index 00000000000..8cbd2f4632b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts @@ -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}`); + } +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/validateCronField.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/validateCronField.ts new file mode 100644 index 00000000000..71600bbacb5 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/validateCronField.ts @@ -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 = { + 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; +}; diff --git a/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts b/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts new file mode 100644 index 00000000000..26c3ea504a3 --- /dev/null +++ b/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts @@ -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(''); + }); +}); diff --git a/packages/twenty-front/src/utils/format/__tests__/getOrdinalNumber.test.ts b/packages/twenty-front/src/utils/format/__tests__/getOrdinalNumber.test.ts new file mode 100644 index 00000000000..4f44f1cfdc0 --- /dev/null +++ b/packages/twenty-front/src/utils/format/__tests__/getOrdinalNumber.test.ts @@ -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'); + }); +}); diff --git a/packages/twenty-front/src/utils/format/formatTime.ts b/packages/twenty-front/src/utils/format/formatTime.ts new file mode 100644 index 00000000000..898c0012ede --- /dev/null +++ b/packages/twenty-front/src/utils/format/formatTime.ts @@ -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}`; + } +}; diff --git a/packages/twenty-front/src/utils/format/getOrdinalNumber.ts b/packages/twenty-front/src/utils/format/getOrdinalNumber.ts new file mode 100644 index 00000000000..65e6819cb1a --- /dev/null +++ b/packages/twenty-front/src/utils/format/getOrdinalNumber.ts @@ -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`; + } + } +}; diff --git a/packages/twenty-front/src/utils/validation/__tests__/isListValue.test.ts b/packages/twenty-front/src/utils/validation/__tests__/isListValue.test.ts new file mode 100644 index 00000000000..476cf427788 --- /dev/null +++ b/packages/twenty-front/src/utils/validation/__tests__/isListValue.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/utils/validation/__tests__/isNumericRange.test.ts b/packages/twenty-front/src/utils/validation/__tests__/isNumericRange.test.ts new file mode 100644 index 00000000000..9381efb5658 --- /dev/null +++ b/packages/twenty-front/src/utils/validation/__tests__/isNumericRange.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/utils/validation/__tests__/isStepValue.test.ts b/packages/twenty-front/src/utils/validation/__tests__/isStepValue.test.ts new file mode 100644 index 00000000000..158c4cdd1e1 --- /dev/null +++ b/packages/twenty-front/src/utils/validation/__tests__/isStepValue.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/utils/validation/isListValue.ts b/packages/twenty-front/src/utils/validation/isListValue.ts new file mode 100644 index 00000000000..6ecf613059c --- /dev/null +++ b/packages/twenty-front/src/utils/validation/isListValue.ts @@ -0,0 +1,5 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const isListValue = (value: string): boolean => { + return isDefined(value) && value.includes(','); +}; diff --git a/packages/twenty-front/src/utils/validation/isNumericRange.ts b/packages/twenty-front/src/utils/validation/isNumericRange.ts new file mode 100644 index 00000000000..49d45dd11b5 --- /dev/null +++ b/packages/twenty-front/src/utils/validation/isNumericRange.ts @@ -0,0 +1,5 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const isNumericRange = (value: string): boolean => { + return isDefined(value) && /^\d+(-\d+)?$/.test(value); +}; diff --git a/packages/twenty-front/src/utils/validation/isStepValue.ts b/packages/twenty-front/src/utils/validation/isStepValue.ts new file mode 100644 index 00000000000..b348907ea1e --- /dev/null +++ b/packages/twenty-front/src/utils/validation/isStepValue.ts @@ -0,0 +1,5 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const isStepValue = (value: string): boolean => { + return isDefined(value) && value.includes('/'); +}; diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 765c5215300..08586b05b0b 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -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", diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts index f7d01a5ce37..831197812cd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts @@ -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", ); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts index 42b28753f3a..3c9ea34a633 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts @@ -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`, diff --git a/yarn.lock b/yarn.lock index dae40335434..4d742f49098 100644 --- a/yarn.lock +++ b/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"