Skip to content

Commit 77fd2ad

Browse files
feat(schedule-actions): add pause form with warning banner (SLICE-2b)
Replace confirmation-only pause with a required reason form, static warning banner, and modal shell extensions (banner slot, initialFormValues, 900px). Resume stays confirmation-only until SLICE-2c. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7e00a76 commit 77fd2ad

14 files changed

Lines changed: 219 additions & 24 deletions

src/views/schedule-actions/__fixtures__/schedule-actions-config.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
MdPauseCircleOutline,
44
MdPlayCircleOutline,
55
} from 'react-icons/md';
6+
import { z } from 'zod';
67

78
import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
89
import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/unpause-schedule.types';
@@ -18,7 +19,7 @@ const mockActionApiRoute =
1819

1920
export const mockPauseActionConfig: ScheduleAction<
2021
PauseScheduleResponse,
21-
undefined,
22+
{ reason: string },
2223
{ reason: string }
2324
> = {
2425
id: 'pause',
@@ -27,18 +28,32 @@ export const mockPauseActionConfig: ScheduleAction<
2728
modal: {
2829
banner: {
2930
kind: 'warning',
30-
icon: MdOutlineWarningAmber,
31+
icon: ({ size }) => <MdOutlineWarningAmber size={size} />,
3132
render: () => 'Mock pause banner message',
3233
},
33-
withForm: false,
34+
withForm: true,
35+
form: ({ control, fieldErrors }) => (
36+
<div data-testid="mock-pause-form">
37+
<input
38+
data-testid="mock-pause-reason"
39+
aria-label="Reason"
40+
aria-invalid={!!fieldErrors.reason}
41+
{...control.register('reason')}
42+
/>
43+
</div>
44+
),
45+
formSchema: z.object({
46+
reason: z.string().trim().min(1, 'Reason for pausing is required'),
47+
}),
48+
transformFormDataToSubmission: (formData) => formData,
49+
initialFormValues: { reason: '' },
3450
},
3551
icon: MdPauseCircleOutline,
3652
getRunnableStatus: (schedule) =>
3753
schedule.state?.paused
3854
? 'NOT_RUNNABLE_SCHEDULE_ALREADY_PAUSED'
3955
: 'RUNNABLE',
4056
apiRoute: mockActionApiRoute('pause'),
41-
getConfirmSubmissionData: () => ({ reason: 'Mock pause reason' }),
4257
renderSuccessMessage: () => 'Mock pause notification',
4358
};
4459

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { MdOutlineWarningAmber } from 'react-icons/md';
2+
3+
import { type ScheduleActionIcon } from '../schedule-actions.types';
4+
5+
export const pauseScheduleBannerIcon: ScheduleActionIcon = ({ size }) => (
6+
<MdOutlineWarningAmber size={size} />
7+
);

src/views/schedule-actions/config/schedule-actions.config.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1-
import {
2-
MdOutlineWarningAmber,
3-
MdPauseCircleOutline,
4-
MdPlayCircleOutline,
5-
} from 'react-icons/md';
1+
import { MdPauseCircleOutline, MdPlayCircleOutline } from 'react-icons/md';
62

73
import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
84
import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/unpause-schedule.types';
95

6+
import ScheduleActionPauseForm from '../schedule-action-pause-form/schedule-action-pause-form';
7+
import { type PauseScheduleFormData } from '../schedule-action-pause-form/schedule-action-pause-form.types';
8+
import { pauseScheduleFormSchema } from '../schedule-action-pause-form/schemas/pause-schedule-form-schema';
109
import {
1110
type PauseScheduleSubmissionData,
1211
type ScheduleAction,
1312
} from '../schedule-actions.types';
1413

14+
import { pauseScheduleBannerIcon } from './schedule-actions-banner-icons';
15+
import { PAUSE_SCHEDULE_MODAL_BANNER_MESSAGE } from './schedule-actions.constants';
16+
1517
const pauseScheduleActionConfig: ScheduleAction<
1618
PauseScheduleResponse,
17-
undefined,
19+
PauseScheduleFormData,
1820
PauseScheduleSubmissionData
1921
> = {
2022
id: 'pause',
@@ -23,11 +25,14 @@ const pauseScheduleActionConfig: ScheduleAction<
2325
modal: {
2426
banner: {
2527
kind: 'warning',
26-
icon: MdOutlineWarningAmber,
27-
render: () =>
28-
'Pausing stops new executions but does not stop workflows already in progress.',
28+
icon: pauseScheduleBannerIcon,
29+
render: () => PAUSE_SCHEDULE_MODAL_BANNER_MESSAGE,
2930
},
30-
withForm: false,
31+
withForm: true,
32+
form: ScheduleActionPauseForm,
33+
formSchema: pauseScheduleFormSchema,
34+
transformFormDataToSubmission: (formData) => formData,
35+
initialFormValues: { reason: '' },
3136
},
3237
icon: MdPauseCircleOutline,
3338
getRunnableStatus: (schedule) =>
@@ -36,10 +41,6 @@ const pauseScheduleActionConfig: ScheduleAction<
3641
: 'RUNNABLE',
3742
apiRoute: (params) =>
3843
`/api/domains/${encodeURIComponent(params.domain)}/${encodeURIComponent(params.cluster)}/schedules/${encodeURIComponent(params.scheduleId)}/pause`,
39-
// TODO: get reason from UI form
40-
getConfirmSubmissionData: () => ({
41-
reason: 'Paused from Cadence Web UI',
42-
}),
4344
renderSuccessMessage: () => 'Schedule has been paused.',
4445
};
4546

@@ -48,6 +49,11 @@ const resumeScheduleActionConfig: ScheduleAction<UnpauseScheduleResponse> = {
4849
label: 'Resume',
4950
subtitle: 'Resume a paused schedule',
5051
modal: {
52+
text: 'Resumes the schedule so new workflow runs can be triggered again.',
53+
docsLink: {
54+
text: 'Read more about schedules',
55+
href: 'https://cadenceworkflow.io/docs/concepts/schedules',
56+
},
5157
withForm: false,
5258
},
5359
icon: MdPlayCircleOutline,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const PAUSE_SCHEDULE_MODAL_BANNER_MESSAGE =
2+
'Pausing stops new executions but does not stop workflows already in progress.';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS = {
2+
reason: 'schedule-action-pause-reason',
3+
} as const;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Textarea } from 'baseui/textarea';
2+
import { Controller } from 'react-hook-form';
3+
4+
import DomainSchedulesHorizontalField from '@/views/domain-schedules/domain-schedules-horizontal-field/domain-schedules-horizontal-field';
5+
import getFieldErrorMessage from '@/views/workflow-actions/workflow-action-start-form/helpers/get-field-error-message';
6+
7+
import { SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS } from './schedule-action-pause-form.constants';
8+
import { type Props } from './schedule-action-pause-form.types';
9+
10+
export default function ScheduleActionPauseForm({
11+
fieldErrors,
12+
control,
13+
}: Props) {
14+
return (
15+
<DomainSchedulesHorizontalField
16+
label="Reason for pausing"
17+
htmlFor={SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS.reason}
18+
error={getFieldErrorMessage(fieldErrors, 'reason')}
19+
>
20+
<Controller
21+
name="reason"
22+
control={control}
23+
defaultValue=""
24+
render={({ field: { ref, ...field } }) => (
25+
<Textarea
26+
{...field}
27+
id={SCHEDULE_ACTION_PAUSE_FORM_FIELD_IDS.reason}
28+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
29+
inputRef={ref}
30+
size="compact"
31+
onChange={(e) => {
32+
field.onChange(e.target.value);
33+
}}
34+
onBlur={field.onBlur}
35+
error={Boolean(getFieldErrorMessage(fieldErrors, 'reason'))}
36+
placeholder="Add reason"
37+
/>
38+
)}
39+
/>
40+
</DomainSchedulesHorizontalField>
41+
);
42+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { type z } from 'zod';
2+
3+
import { type ScheduleActionFormProps } from '../schedule-actions.types';
4+
5+
import { type pauseScheduleFormSchema } from './schemas/pause-schedule-form-schema';
6+
7+
export type PauseScheduleFormData = z.infer<typeof pauseScheduleFormSchema>;
8+
9+
export type Props = Pick<
10+
ScheduleActionFormProps<PauseScheduleFormData>,
11+
'fieldErrors' | 'control'
12+
>;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { z } from 'zod';
2+
3+
export const pauseScheduleFormSchema = z.object({
4+
reason: z.string().trim().min(1, 'Reason for pausing is required'),
5+
});

src/views/schedule-actions/schedule-actions-modal-content/__tests__/schedule-actions-modal-content.test.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { HttpResponse } from 'msw';
22

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

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

79
import { mockScheduleActionsConfig } from '../../__fixtures__/schedule-actions-config';
810
import { type ScheduleAction } from '../../schedule-actions.types';
@@ -58,6 +60,10 @@ describe(ScheduleActionsModalContent.name, () => {
5860
{}
5961
);
6062

63+
await user.type(
64+
screen.getByTestId('mock-pause-reason'),
65+
'Mock pause reason'
66+
);
6167
await user.click(
6268
await screen.findByRole('button', { name: 'Mock pause schedule' })
6369
);
@@ -75,9 +81,21 @@ describe(ScheduleActionsModalContent.name, () => {
7581
expect(mockOnClose).toHaveBeenCalled();
7682
});
7783

84+
it('renders pause banner without describe schedule data', async () => {
85+
setup({ schedule: undefined });
86+
87+
expect(
88+
await screen.findByText('Mock pause banner message')
89+
).toBeInTheDocument();
90+
});
91+
7892
it('displays banner when the action fails', async () => {
7993
const { user, mockOnClose } = setup({ error: true });
8094

95+
await user.type(
96+
screen.getByTestId('mock-pause-reason'),
97+
'Mock pause reason'
98+
);
8199
await user.click(
82100
await screen.findByRole('button', { name: 'Mock pause schedule' })
83101
);
@@ -90,14 +108,50 @@ describe(ScheduleActionsModalContent.name, () => {
90108
});
91109
expect(mockOnClose).not.toHaveBeenCalled();
92110
});
111+
112+
describe('form handling', () => {
113+
it('renders form when provided in action config', () => {
114+
setup({});
115+
116+
expect(screen.getByTestId('mock-pause-form')).toBeInTheDocument();
117+
expect(screen.getByTestId('mock-pause-reason')).toBeInTheDocument();
118+
});
119+
120+
it('disables submit button when form has validation errors', async () => {
121+
const { user } = setup({});
122+
123+
const submitButton = screen.getByRole('button', {
124+
name: 'Mock pause schedule',
125+
});
126+
await user.click(submitButton);
127+
128+
expect(submitButton).toHaveAttribute('disabled');
129+
});
130+
131+
it('shows validation error when reason is empty', async () => {
132+
const { user } = setup({});
133+
134+
const submitButton = screen.getByRole('button', {
135+
name: 'Mock pause schedule',
136+
});
137+
await user.click(submitButton);
138+
139+
expect(screen.getByTestId('mock-pause-reason')).toHaveAttribute(
140+
'aria-invalid',
141+
'true'
142+
);
143+
});
144+
});
93145
});
94146

95147
function setup({
96148
error,
97149
actionConfig,
150+
schedule = mockDescribeScheduleResponse,
98151
}: {
99152
error?: boolean;
100153
actionConfig?: ScheduleAction<any, any, any>;
154+
schedule?: typeof mockDescribeScheduleResponse;
101155
}) {
102156
const user = userEvent.setup();
103157
const mockOnClose = jest.fn();
@@ -111,6 +165,7 @@ function setup({
111165
<ScheduleActionsModalContent
112166
action={actionConfig ?? mockScheduleActionsConfig[0]}
113167
params={{ ...mockScheduleParams }}
168+
schedule={schedule}
114169
onCloseModal={mockOnClose}
115170
/>,
116171
{
@@ -131,7 +186,9 @@ function setup({
131186
);
132187
}
133188

134-
return HttpResponse.json({} satisfies PauseScheduleResponse);
189+
return HttpResponse.json(
190+
{} satisfies PauseScheduleResponse | UnpauseScheduleResponse
191+
);
135192
},
136193
},
137194
],

src/views/schedule-actions/schedule-actions-modal-content/schedule-actions-modal-content.styles.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { styled as createStyled, type Theme, withStyle } from 'baseui';
22
import { type BannerOverrides } from 'baseui/banner';
3+
import { StyledLink } from 'baseui/link';
34
import { ModalBody, ModalFooter, ModalHeader } from 'baseui/modal';
45
import { type StyleObject } from 'styletron-react';
56

@@ -33,6 +34,13 @@ export const styled = {
3334
display: 'flex',
3435
justifyContent: 'space-between',
3536
}),
37+
Link: withStyle(StyledLink, ({ $theme }: { $theme: Theme }) => ({
38+
alignSelf: 'start',
39+
...$theme.typography.LabelSmall,
40+
display: 'flex',
41+
alignItems: 'center',
42+
columnGap: $theme.sizing.scale100,
43+
})),
3644
};
3745

3846
export const overrides = {

0 commit comments

Comments
 (0)