Skip to content
Closed
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
@@ -1,4 +1,9 @@
import { MdPauseCircleOutline, MdPlayCircleOutline } from 'react-icons/md';
import {
MdOutlineWarningAmber,
MdPauseCircleOutline,
MdPlayCircleOutline,
} from 'react-icons/md';
import { z } from 'zod';

import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/unpause-schedule.types';
Expand All @@ -14,27 +19,41 @@ const mockActionApiRoute =

export const mockPauseActionConfig: ScheduleAction<
PauseScheduleResponse,
undefined,
{ reason: string },
{ reason: string }
> = {
id: 'pause',
label: 'Mock pause',
subtitle: 'Mock pause a schedule',
modal: {
text: 'Mock modal text to pause a schedule',
docsLink: {
text: 'Mock docs link',
href: 'https://mock.docs.link',
banner: {
kind: 'warning',
icon: ({ size }) => <MdOutlineWarningAmber size={size} />,
render: () => 'Mock pause banner message',
},
withForm: false,
withForm: true,
form: ({ control, fieldErrors }) => (
<div data-testid="mock-pause-form">
<input
data-testid="mock-pause-reason"
aria-label="Reason"
aria-invalid={!!fieldErrors.reason}
{...control.register('reason')}
/>
</div>
),
formSchema: z.object({
reason: z.string().trim().min(1, 'Reason for pausing is required'),
}),
transformFormDataToSubmission: (formData) => formData,
initialFormValues: { reason: '' },
},
icon: MdPauseCircleOutline,
getRunnableStatus: (schedule) =>
schedule.state?.paused
? 'NOT_RUNNABLE_SCHEDULE_ALREADY_PAUSED'
: 'RUNNABLE',
apiRoute: mockActionApiRoute('pause'),
getConfirmSubmissionData: () => ({ reason: 'Mock pause reason' }),
renderSuccessMessage: () => 'Mock pause notification',
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import { HttpResponse } from 'msw';

import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';

import { mockDescribeScheduleResponse } from '@/route-handlers/describe-schedule/__fixtures__/mock-describe-schedule-response';

import { mockScheduleActionsConfig } from '../__fixtures__/schedule-actions-config';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { MdOutlineWarningAmber } from 'react-icons/md';

import { type ScheduleActionBannerIcon } from '../schedule-actions.types';

export const pauseScheduleBannerIcon: ScheduleActionBannerIcon = ({ size }) => (
<MdOutlineWarningAmber size={size} />
);
33 changes: 18 additions & 15 deletions src/views/schedule-actions/config/schedule-actions.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,35 @@ import { MdPauseCircleOutline, MdPlayCircleOutline } from 'react-icons/md';
import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/unpause-schedule.types';

import ScheduleActionPauseForm from '../schedule-action-pause-form/schedule-action-pause-form';
import { type PauseScheduleFormData } from '../schedule-action-pause-form/schedule-action-pause-form.types';
import { pauseScheduleFormSchema } from '../schedule-action-pause-form/schemas/pause-schedule-form-schema';
import { type ScheduleAction } from '../schedule-actions.types';

export type PauseScheduleSubmissionData = {
reason: string;
};
import { pauseScheduleBannerIcon } from './schedule-actions-banner-icons';
import { PAUSE_SCHEDULE_MODAL_BANNER_MESSAGE } from './schedule-actions.constants';

export type PauseScheduleSubmissionData = PauseScheduleFormData;

const pauseScheduleActionConfig: ScheduleAction<
PauseScheduleResponse,
undefined,
PauseScheduleFormData,
PauseScheduleSubmissionData
> = {
id: 'pause',
label: 'Pause',
subtitle: 'Pause a schedule',
modal: {
text: 'Pauses the schedule so no new workflow runs are triggered until it is resumed.',
docsLink: {
text: 'Read more about pausing schedules',
href: 'https://cadenceworkflow.io/docs/concepts/schedules',
banner: {
kind: 'warning',
icon: pauseScheduleBannerIcon,
render: () => PAUSE_SCHEDULE_MODAL_BANNER_MESSAGE,
},
withForm: false,
withForm: true,
form: ScheduleActionPauseForm,
formSchema: pauseScheduleFormSchema,
transformFormDataToSubmission: (formData) => formData,
initialFormValues: { reason: '' },
},
icon: MdPauseCircleOutline,
getRunnableStatus: (schedule) =>
Expand All @@ -32,15 +40,10 @@ const pauseScheduleActionConfig: ScheduleAction<
: 'RUNNABLE',
apiRoute: (params) =>
`/api/domains/${encodeURIComponent(params.domain)}/${encodeURIComponent(params.cluster)}/schedules/${encodeURIComponent(params.scheduleId)}/pause`,
getConfirmSubmissionData: () => ({
reason: 'Paused from Cadence Web UI',
}),
renderSuccessMessage: () => 'Schedule has been paused.',
};

const resumeScheduleActionConfig: ScheduleAction<
UnpauseScheduleResponse
> = {
const resumeScheduleActionConfig: ScheduleAction<UnpauseScheduleResponse> = {
id: 'resume',
label: 'Resume',
subtitle: 'Resume a paused schedule',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const PAUSE_SCHEDULE_MODAL_BANNER_MESSAGE =
'Pausing stops new executions but does not stop workflows already in progress.';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS = {
reason: 'schedule-action-pause-reason',
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Textarea } from 'baseui/textarea';
import { Controller } from 'react-hook-form';

import DomainSchedulesHorizontalField from '@/views/domain-schedules/domain-schedules-horizontal-field/domain-schedules-horizontal-field';
import getFieldErrorMessage from '@/views/workflow-actions/workflow-action-start-form/helpers/get-field-error-message';

import { SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS } from './schedule-action-pause-form.constants';
import { type Props } from './schedule-action-pause-form.types';

export default function ScheduleActionPauseForm({
fieldErrors,
control,
}: Props) {
return (
<DomainSchedulesHorizontalField
label="Reason for pausing"
htmlFor={SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS.reason}
error={getFieldErrorMessage(fieldErrors, 'reason')}
>
<Controller
name="reason"
control={control}
defaultValue=""
render={({ field: { ref, ...field } }) => (
<Textarea
{...field}
id={SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS.reason}
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
inputRef={ref}
size="compact"
onChange={(e) => {
field.onChange(e.target.value);
}}
onBlur={field.onBlur}
error={Boolean(getFieldErrorMessage(fieldErrors, 'reason'))}
placeholder="Add reason"
/>
)}
/>
</DomainSchedulesHorizontalField>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type z } from 'zod';

import { type ScheduleActionFormProps } from '../schedule-actions.types';

import { type pauseScheduleFormSchema } from './schemas/pause-schedule-form-schema';

export type PauseScheduleFormData = z.infer<typeof pauseScheduleFormSchema>;

export type Props = Pick<
ScheduleActionFormProps<PauseScheduleFormData>,
'fieldErrors' | 'control'
>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { z } from 'zod';

export const pauseScheduleFormSchema = z.object({
reason: z.string().trim().min(1, 'Reason for pausing is required'),
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';

import { render, screen, userEvent, within } from '@/test-utils/rtl';

import {
getMockPausedDescribeScheduleResponse,
getMockRunningDescribeScheduleResponse,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type ScheduleActionRunnableStatus } from '../../../schedule-actions.types';

import getActionDisabledReason from '../get-action-disabled-reason';

describe(getActionDisabledReason.name, () => {
Expand Down Expand Up @@ -28,7 +27,9 @@ describe(getActionDisabledReason.name, () => {
it('returns undefined when runnable status is not provided', () => {
expect(
getActionDisabledReason({
actionRunnableStatus: undefined as ScheduleActionRunnableStatus | undefined,
actionRunnableStatus: undefined as
| ScheduleActionRunnableStatus
| undefined,
})
).toBeUndefined();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { HttpResponse } from 'msw';

import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';

import { mockDescribeScheduleResponse } from '@/route-handlers/describe-schedule/__fixtures__/mock-describe-schedule-response';
import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/unpause-schedule.types';

import { mockScheduleActionsConfig } from '../../__fixtures__/schedule-actions-config';
import { type ScheduleAction } from '../../schedule-actions.types';
Expand Down Expand Up @@ -39,9 +41,7 @@ describe(ScheduleActionsModalContent.name, () => {
setup({});

expect(await screen.findAllByText('Mock pause schedule')).toHaveLength(2);
expect(
screen.getByText('Mock modal text to pause a schedule')
).toBeInTheDocument();
expect(screen.getByText('Mock pause banner message')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Mock pause schedule' })
).toBeInTheDocument();
Expand All @@ -55,9 +55,14 @@ describe(ScheduleActionsModalContent.name, () => {
});

it('calls pause API, sends toast, and closes modal when confirmed', async () => {
const { user, mockOnClose, getLatestRequestBody, waitForRequest } =
setup({});
const { user, mockOnClose, getLatestRequestBody, waitForRequest } = setup(
{}
);

await user.type(
screen.getByTestId('mock-pause-reason'),
'Mock pause reason'
);
await user.click(
await screen.findByRole('button', { name: 'Mock pause schedule' })
);
Expand All @@ -75,9 +80,21 @@ describe(ScheduleActionsModalContent.name, () => {
expect(mockOnClose).toHaveBeenCalled();
});

it('renders pause banner without describe schedule data', async () => {
setup({ schedule: undefined });

expect(
await screen.findByText('Mock pause banner message')
).toBeInTheDocument();
});

it('displays banner when the action fails', async () => {
const { user, mockOnClose } = setup({ error: true });

await user.type(
screen.getByTestId('mock-pause-reason'),
'Mock pause reason'
);
await user.click(
await screen.findByRole('button', { name: 'Mock pause schedule' })
);
Expand All @@ -87,14 +104,50 @@ describe(ScheduleActionsModalContent.name, () => {
});
expect(mockOnClose).not.toHaveBeenCalled();
});

describe('form handling', () => {
it('renders form when provided in action config', () => {
setup({});

expect(screen.getByTestId('mock-pause-form')).toBeInTheDocument();
expect(screen.getByTestId('mock-pause-reason')).toBeInTheDocument();
});

it('disables submit button when form has validation errors', async () => {
const { user } = setup({});

const submitButton = screen.getByRole('button', {
name: 'Mock pause schedule',
});
await user.click(submitButton);

expect(submitButton).toHaveAttribute('disabled');
});

it('shows validation error when reason is empty', async () => {
const { user } = setup({});

const submitButton = screen.getByRole('button', {
name: 'Mock pause schedule',
});
await user.click(submitButton);

expect(screen.getByTestId('mock-pause-reason')).toHaveAttribute(
'aria-invalid',
'true'
);
});
});
});

function setup({
error,
actionConfig,
schedule = mockDescribeScheduleResponse,
}: {
error?: boolean;
actionConfig?: ScheduleAction<any, any, any>;
schedule?: typeof mockDescribeScheduleResponse;
}) {
const user = userEvent.setup();
const mockOnClose = jest.fn();
Expand All @@ -108,6 +161,7 @@ function setup({
<ScheduleActionsModalContent
action={actionConfig ?? mockScheduleActionsConfig[0]}
params={{ ...mockScheduleParams }}
schedule={schedule}
onCloseModal={mockOnClose}
/>,
{
Expand All @@ -128,7 +182,9 @@ function setup({
);
}

return HttpResponse.json({} satisfies PauseScheduleResponse);
return HttpResponse.json(
{} satisfies PauseScheduleResponse | UnpauseScheduleResponse
);
},
},
],
Expand Down
Loading
Loading