Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ describe(CronScheduleInputPopover.name, () => {
setup({ fieldType: 'daysOfMonth' });

expect(screen.getByText('Day of Month')).toBeInTheDocument();
expect(screen.getByText('0-31')).toBeInTheDocument();
expect(screen.getByText('1-31')).toBeInTheDocument();
});

it('should render with months field type and show month aliases', () => {
setup({ fieldType: 'months' });

expect(screen.getByText('Month')).toBeInTheDocument();
expect(screen.getByText('0-12')).toBeInTheDocument();
expect(screen.getByText('1-12')).toBeInTheDocument();
expect(screen.getByText('JAN-DEC')).toBeInTheDocument();
expect(screen.getByText('alternative single values')).toBeInTheDocument();
});
Expand Down Expand Up @@ -82,8 +82,8 @@ describe(CronScheduleInputPopover.name, () => {
const testCases = [
{ fieldType: 'minutes' as const, expectedRange: '0-59' },
{ fieldType: 'hours' as const, expectedRange: '0-23' },
{ fieldType: 'daysOfMonth' as const, expectedRange: '0-31' },
{ fieldType: 'months' as const, expectedRange: '0-12' },
{ fieldType: 'daysOfMonth' as const, expectedRange: '1-31' },
{ fieldType: 'months' as const, expectedRange: '1-12' },
{ fieldType: 'daysOfWeek' as const, expectedRange: '0-6' },
];

Expand Down
4 changes: 2 additions & 2 deletions src/utils/cron-validate/__tests__/cron-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ describe('cronValidate', () => {
maxValue: 23,
});
expect(CRON_VALIDATE_CADENCE_PRESET.daysOfMonth).toEqual({
minValue: 0,
minValue: 1,
maxValue: 31,
});
expect(CRON_VALIDATE_CADENCE_PRESET.months).toEqual({
minValue: 0,
minValue: 1,
maxValue: 12,
});
expect(CRON_VALIDATE_CADENCE_PRESET.daysOfWeek).toEqual({
Expand Down
4 changes: 2 additions & 2 deletions src/utils/cron-validate/cron-validate.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ export const CRON_VALIDATE_CADENCE_PRESET = {
maxValue: 23,
},
daysOfMonth: {
minValue: 0,
minValue: 1, // starting from 1 instead of 0, 0 is not standard
maxValue: 31,
},
months: {
minValue: 0,
minValue: 1, // starting from 1 instead of 0, 0 is not standard
maxValue: 12,
},
daysOfWeek: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ function TestWrapper({ formErrors, formData }: TestProps) {

return (
<WorkflowActionResetForm
trigger={methods.trigger}
control={methods.control}
clearErrors={methods.clearErrors}
fieldErrors={formErrors}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function TestWrapper({ formErrors, formData }: TestProps) {

return (
<WorkflowActionSignalForm
trigger={methods.trigger}
control={methods.control}
clearErrors={methods.clearErrors}
fieldErrors={formErrors}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,26 @@ describe('WorkflowActionStartForm', () => {
);

await user.click(screen.getByRole('radio', { name: 'Cron' }));
expect(
screen.getByRole('textbox', { name: 'Cron Schedule (UTC)' })
).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByLabelText('Minute')).toHaveAttribute(
'aria-invalid',
'true'
);
expect(screen.getByLabelText('Hour')).toHaveAttribute(
'aria-invalid',
'true'
);
expect(screen.getByLabelText('Day of Month')).toHaveAttribute(
'aria-invalid',
'true'
);
expect(screen.getByLabelText('Month')).toHaveAttribute(
'aria-invalid',
'true'
);
expect(screen.getByLabelText('Day of Week')).toHaveAttribute(
'aria-invalid',
'true'
);
});

it('handles input changes correctly', async () => {
Expand Down Expand Up @@ -201,6 +218,7 @@ function TestWrapper({ formErrors, formData }: TestProps) {

return (
<WorkflowActionStartForm
trigger={methods.trigger}
control={methods.control}
clearErrors={methods.clearErrors}
fieldErrors={formErrors}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { cronValidate } from '@/utils/cron-validate/cron-validate';

import { getCronFieldsError } from '../get-cron-fields-error';

jest.mock('@/utils/cron-validate/cron-validate');

const mockCronValidate = cronValidate as jest.MockedFunction<
typeof cronValidate
>;

type CronValidateResult = ReturnType<typeof cronValidate>;

describe('getCronFieldsError', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return null for valid cron expression', () => {
const { result } = setup({
isValid: true,
});

expect(result).toBeNull();
});

it('should handle field-specific errors correctly', () => {
const { result } = setup({
isValid: false,
errors: [
'Invalid value for minutes field: x',
'Invalid value for hours field: x',
'Invalid value for daysOfMonth field: x',
'Invalid value for months field: x',
'Invalid value for daysOfWeek field: x',
],
});

expect(result).toEqual({
minutes: 'Invalid value for minutes field: x',
hours: 'Invalid value for hours field: x',
daysOfWeek: 'Invalid value for daysOfWeek field: x',
daysOfMonth: 'Invalid value for daysOfMonth field: x',
months: 'Invalid value for months field: x',
});
});

it('should return general error when errors array has no field-specific matches', () => {
const { result } = setup({
isValid: false,
errors: ['Malformed cron expression', 'Cannot parse input'],
});

expect(result).toEqual({
general: 'Malformed cron expression',
});
});

it('should handle mixed field-specific and general errors', () => {
const { result } = setup({
isValid: false,
errors: [
'Invalid value for minutes field: abc',
'Syntax error in expression',
'Invalid value for hours field: xyz',
],
});

// Should prioritize field-specific errors over general ones
expect(result).toEqual({
minutes: 'Invalid value for minutes field: abc',
hours: 'Invalid value for hours field: xyz',
});
});
});

function setup({
cronText = '* * * * *',
isValid = true,
errors = [],
}: {
cronText?: string;
isValid?: boolean;
errors?: string[];
}) {
const mockCronResult: Partial<CronValidateResult> = {
isValid: jest.fn(() => isValid),
getError: jest.fn(() => errors),
};

mockCronValidate.mockReturnValue(mockCronResult as CronValidateResult);

const result = getCronFieldsError(cronText);

return {
result,
mockCronResult,
mockIsValid: mockCronResult.isValid,
mockGetError: mockCronResult.getError,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@ describe('transformStartWorkflowFormToSubmission', () => {
const formData: StartWorkflowFormData = {
...baseFormData,
scheduleType: 'CRON',
cronSchedule: '0 9 * * 1-5',
cronSchedule: {
minutes: '0',
hours: '9',
daysOfMonth: '*',
months: '*',
daysOfWeek: '1-5',
},
};

const result = transformStartWorkflowFormToSubmission(formData);
Expand All @@ -50,7 +56,13 @@ describe('transformStartWorkflowFormToSubmission', () => {
...baseFormData,
scheduleType: 'NOW',
firstRunAt: '2024-01-01T10:00:00Z',
cronSchedule: '0 9 * * 1-5',
cronSchedule: {
minutes: '0',
hours: '9',
daysOfMonth: '*',
months: '*',
daysOfWeek: '1-5',
},
};

const result = transformStartWorkflowFormToSubmission(formData);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CRON_FIELD_ORDER } from '@/components/cron-schedule-input/cron-schedule-input.constants';
import { cronValidate } from '@/utils/cron-validate/cron-validate';
import { type CronData } from '@/utils/cron-validate/cron-validate.types';

import { type CronFieldsError } from '../workflow-action-start-form.types';

export const getCronFieldsError = (cronString: string): CronFieldsError => {
const cronObj = cronValidate(cronString);

if (!cronObj.isValid()) {
const errors = cronObj.getError();
const errorFieldsKeys = CRON_FIELD_ORDER;
const fieldsErrors: Partial<Record<keyof CronData, string>> = {};
errors.forEach((e) => {
const errorKey = errorFieldsKeys.find((key) =>
e.includes(`${key} field`)
);
if (errorKey) fieldsErrors[errorKey] = e;
});

if (!Object.keys(fieldsErrors).length) return { general: errors[0] };
else return fieldsErrors;
}

return null;
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CRON_FIELD_ORDER } from '@/components/cron-schedule-input/cron-schedule-input.constants';
import { type Json } from '@/route-handlers/start-workflow/start-workflow.types';

import {
Expand All @@ -22,7 +23,9 @@ export default function transformStartWorkflowFormToSubmission(
firstRunAt: formData.firstRunAt,
}),
...(formData.scheduleType === 'CRON' && {
cronSchedule: formData.cronSchedule,
cronSchedule: CRON_FIELD_ORDER.map(
(key) => formData.cronSchedule?.[key]
).join(' '),
}),
...(formData.enableRetryPolicy && {
retryPolicy: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { z } from 'zod';

import { CRON_FIELD_ORDER } from '@/components/cron-schedule-input/cron-schedule-input.constants';
import {
WORKER_SDK_LANGUAGES,
WORKFLOW_ID_REUSE_POLICIES,
} from '@/route-handlers/start-workflow/start-workflow.constants';

import { getCronFieldsError } from '../helpers/get-cron-fields-error';

const baseSchema = z.object({
workflowType: z.object({
name: z.string().min(1, 'Workflow type name is required'),
Expand Down Expand Up @@ -81,8 +84,45 @@ const baseSchema = z.object({
// Schedule type fields
scheduleType: z.enum(['NOW', 'LATER', 'CRON']),
firstRunAt: z.string().optional(),
cronSchedule: z.string().optional(),
cronSchedule: z
.object({
minutes: z.string().min(1, 'Minutes is required'),
hours: z.string().min(1, 'Hours is required'),
daysOfMonth: z.string().min(1, 'Days of month is required'),
months: z.string().min(1, 'Months is required'),
daysOfWeek: z.string().min(1, 'Days of week is required'),
})
.superRefine((data, ctx) => {
const allFieldsHasValue = Object.values(data).every((value) =>
Boolean(value)
);

// If there are missing fields, no need to validate the cron schedule format.
if (!allFieldsHasValue) {
return;
}

const cronString = CRON_FIELD_ORDER.map((key) => data[key]).join(' ');
const cronFieldsErrors = getCronFieldsError(cronString);

if (!cronFieldsErrors) return;

if (cronFieldsErrors?.general) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid cron schedule format',
});
} else {
Object.entries(cronFieldsErrors).forEach(([errorKey, errorMessage]) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: errorMessage,
path: [errorKey],
});
});
}
})
.optional(),
// Retry policy fields
enableRetryPolicy: z.boolean().optional(),
limitRetries: z.enum(['ATTEMPTS', 'DURATION']).optional(),
Expand Down
Loading
Loading