Skip to content

Commit 45e7995

Browse files
feat(schedule-actions): add resume form with catch-up policy (SLICE-2c)
Replace confirmation-only resume with optional reason and catch-up policy radios. Stacks on mount; default submission omits unset fields. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4f68480 commit 45e7995

10 files changed

Lines changed: 266 additions & 4 deletions

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from 'react-icons/md';
66
import { z } from 'zod';
77

8+
import { ScheduleCatchUpPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleCatchUpPolicy';
89
import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
910
import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/unpause-schedule.types';
1011

@@ -58,12 +59,36 @@ export const mockPauseActionConfig: ScheduleAction<
5859
renderSuccessMessage: () => 'Mock pause notification',
5960
};
6061

61-
export const mockResumeActionConfig: ScheduleAction<UnpauseScheduleResponse> = {
62+
export const mockResumeActionConfig: ScheduleAction<
63+
UnpauseScheduleResponse,
64+
{ reason?: string; catchUpPolicy?: ScheduleCatchUpPolicy },
65+
{ reason?: string; catchUpPolicy?: ScheduleCatchUpPolicy }
66+
> = {
6267
id: 'resume',
6368
label: 'Mock resume',
6469
subtitle: 'Mock resume a schedule',
6570
modal: {
66-
withForm: false,
71+
withForm: true,
72+
form: ({ control, fieldErrors }) => (
73+
<div data-testid="mock-resume-form">
74+
<input
75+
data-testid="mock-resume-reason"
76+
aria-label="Reason (optional)"
77+
aria-invalid={!!fieldErrors.reason}
78+
{...control.register('reason')}
79+
/>
80+
</div>
81+
),
82+
formSchema: z.object({
83+
reason: z.string().optional(),
84+
catchUpPolicy: z.nativeEnum(ScheduleCatchUpPolicy).optional(),
85+
}),
86+
transformFormDataToSubmission: (formData) => ({
87+
...(formData.reason?.trim() ? { reason: formData.reason.trim() } : {}),
88+
...(formData.catchUpPolicy
89+
? { catchUpPolicy: formData.catchUpPolicy }
90+
: {}),
91+
}),
6792
},
6893
icon: MdPlayCircleOutline,
6994
getRunnableStatus: (schedule) =>

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import { type UnpauseScheduleResponse } from '@/route-handlers/unpause-schedule/
66
import ScheduleActionPauseForm from '../schedule-action-pause-form/schedule-action-pause-form';
77
import { type PauseScheduleFormData } from '../schedule-action-pause-form/schedule-action-pause-form.types';
88
import { pauseScheduleFormSchema } from '../schedule-action-pause-form/schemas/pause-schedule-form-schema';
9+
import transformResumeScheduleFormToSubmission from '../schedule-action-resume-form/helpers/transform-resume-schedule-form-to-submission';
10+
import ScheduleActionResumeForm from '../schedule-action-resume-form/schedule-action-resume-form';
11+
import {
12+
type ResumeScheduleFormData,
13+
type ResumeScheduleSubmissionData,
14+
} from '../schedule-action-resume-form/schedule-action-resume-form.types';
15+
import { resumeScheduleFormSchema } from '../schedule-action-resume-form/schemas/resume-schedule-form-schema';
916
import {
1017
type PauseScheduleSubmissionData,
1118
type ScheduleAction,
@@ -43,12 +50,19 @@ const pauseScheduleActionConfig: ScheduleAction<
4350
renderSuccessMessage: () => 'Schedule has been paused.',
4451
};
4552

46-
const resumeScheduleActionConfig: ScheduleAction<UnpauseScheduleResponse> = {
53+
const resumeScheduleActionConfig: ScheduleAction<
54+
UnpauseScheduleResponse,
55+
ResumeScheduleFormData,
56+
ResumeScheduleSubmissionData
57+
> = {
4758
id: 'resume',
4859
label: 'Resume',
4960
subtitle: 'Resume a paused schedule',
5061
modal: {
51-
withForm: false,
62+
withForm: true,
63+
form: ScheduleActionResumeForm,
64+
formSchema: resumeScheduleFormSchema,
65+
transformFormDataToSubmission: transformResumeScheduleFormToSubmission,
5266
},
5367
icon: MdPlayCircleOutline,
5468
getRunnableStatus: (schedule) =>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const RESUME_SCHEDULE_FORM_FIELD_DESCRIPTIONS = {
2+
reason: 'Optional note explaining why the schedule is being resumed.',
3+
catchUpPolicy:
4+
'Catch-up policy determines how to handle missed runs when unpausing a schedule.',
5+
} as const;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ScheduleCatchUpPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleCatchUpPolicy';
2+
3+
import { USE_SCHEDULE_CATCH_UP_POLICY } from '../../schedule-action-resume-form.constants';
4+
import transformResumeScheduleFormToSubmission from '../transform-resume-schedule-form-to-submission';
5+
6+
describe(transformResumeScheduleFormToSubmission.name, () => {
7+
it('omits empty reason and schedule-policy default', () => {
8+
expect(transformResumeScheduleFormToSubmission({})).toEqual({});
9+
expect(transformResumeScheduleFormToSubmission({ reason: ' ' })).toEqual(
10+
{}
11+
);
12+
expect(
13+
transformResumeScheduleFormToSubmission({
14+
catchUpPolicy: USE_SCHEDULE_CATCH_UP_POLICY,
15+
})
16+
).toEqual({});
17+
});
18+
19+
it('includes trimmed reason and explicit catchUpPolicy when provided', () => {
20+
expect(
21+
transformResumeScheduleFormToSubmission({
22+
reason: ' Resuming after maintenance ',
23+
catchUpPolicy: ScheduleCatchUpPolicy.SCHEDULE_CATCH_UP_POLICY_ONE,
24+
})
25+
).toEqual({
26+
reason: 'Resuming after maintenance',
27+
catchUpPolicy: ScheduleCatchUpPolicy.SCHEDULE_CATCH_UP_POLICY_ONE,
28+
});
29+
});
30+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { isExplicitCatchUpPolicy } from '../schedule-action-resume-form.constants';
2+
import {
3+
type ResumeScheduleFormData,
4+
type ResumeScheduleSubmissionData,
5+
} from '../schedule-action-resume-form.types';
6+
7+
export default function transformResumeScheduleFormToSubmission(
8+
formData: ResumeScheduleFormData
9+
): ResumeScheduleSubmissionData {
10+
const reason = formData.reason?.trim();
11+
12+
return {
13+
...(reason ? { reason } : {}),
14+
...(isExplicitCatchUpPolicy(formData.catchUpPolicy)
15+
? { catchUpPolicy: formData.catchUpPolicy }
16+
: {}),
17+
};
18+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { SCHEDULE_CATCH_UP_POLICIES } from '@/route-handlers/create-schedule/create-schedule.constants';
2+
import { SCHEDULE_CATCH_UP_POLICY_LABELS } from '@/views/shared/constants/schedule-policy-labels.constants';
3+
4+
export const USE_SCHEDULE_CATCH_UP_POLICY = 'USE_SCHEDULE_POLICY' as const;
5+
6+
export const DEFAULT_RESUME_CATCH_UP_POLICY = USE_SCHEDULE_CATCH_UP_POLICY;
7+
8+
export const SCHEDULE_ACTION_RESUME_FORM_FIELD_IDS = {
9+
reason: 'schedule-action-resume-reason',
10+
} as const;
11+
12+
export const RESUME_CATCH_UP_POLICY_OPTIONS = [
13+
{
14+
id: USE_SCHEDULE_CATCH_UP_POLICY,
15+
label: 'Schedule default policy',
16+
},
17+
...SCHEDULE_CATCH_UP_POLICIES.map((policy) => ({
18+
id: policy,
19+
label: SCHEDULE_CATCH_UP_POLICY_LABELS[policy],
20+
})),
21+
] as const;
22+
23+
export type ResumeCatchUpPolicyOptionId =
24+
(typeof RESUME_CATCH_UP_POLICY_OPTIONS)[number]['id'];
25+
26+
export type ExplicitResumeCatchUpPolicy =
27+
(typeof SCHEDULE_CATCH_UP_POLICIES)[number];
28+
29+
export const isExplicitCatchUpPolicy = (
30+
value: ResumeCatchUpPolicyOptionId | undefined
31+
): value is ExplicitResumeCatchUpPolicy =>
32+
value !== undefined && value !== USE_SCHEDULE_CATCH_UP_POLICY;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Radio, RadioGroup } from 'baseui/radio';
2+
import { Textarea } from 'baseui/textarea';
3+
import { Controller } from 'react-hook-form';
4+
5+
import DomainSchedulesHorizontalField from '@/views/domain-schedules/domain-schedules-horizontal-field/domain-schedules-horizontal-field';
6+
import { RESUME_SCHEDULE_FORM_FIELD_DESCRIPTIONS } from '@/views/schedule-actions/config/schedule-actions.constants';
7+
import getFieldErrorMessage from '@/views/workflow-actions/workflow-action-start-form/helpers/get-field-error-message';
8+
9+
import {
10+
DEFAULT_RESUME_CATCH_UP_POLICY,
11+
RESUME_CATCH_UP_POLICY_OPTIONS,
12+
SCHEDULE_ACTION_RESUME_FORM_FIELD_IDS,
13+
} from './schedule-action-resume-form.constants';
14+
import { type Props } from './schedule-action-resume-form.types';
15+
16+
export default function ScheduleActionResumeForm({
17+
fieldErrors,
18+
control,
19+
}: Props) {
20+
return (
21+
<>
22+
<DomainSchedulesHorizontalField
23+
label="Catch-up policy"
24+
description={RESUME_SCHEDULE_FORM_FIELD_DESCRIPTIONS.catchUpPolicy}
25+
error={getFieldErrorMessage(fieldErrors, 'catchUpPolicy')}
26+
>
27+
<Controller
28+
name="catchUpPolicy"
29+
control={control}
30+
defaultValue={DEFAULT_RESUME_CATCH_UP_POLICY}
31+
render={({ field: { value, onChange, ref, ...field } }) => (
32+
<RadioGroup
33+
{...field}
34+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
35+
inputRef={ref}
36+
aria-label="Catch-up policy"
37+
value={value}
38+
onChange={(e) => onChange(e.currentTarget.value)}
39+
error={Boolean(
40+
getFieldErrorMessage(fieldErrors, 'catchUpPolicy')
41+
)}
42+
>
43+
{RESUME_CATCH_UP_POLICY_OPTIONS.map((option) => (
44+
<Radio key={option.id} value={option.id}>
45+
{option.label}
46+
</Radio>
47+
))}
48+
</RadioGroup>
49+
)}
50+
/>
51+
</DomainSchedulesHorizontalField>
52+
53+
<DomainSchedulesHorizontalField
54+
label="Reason for resuming (optional)"
55+
description={RESUME_SCHEDULE_FORM_FIELD_DESCRIPTIONS.reason}
56+
htmlFor={SCHEDULE_ACTION_RESUME_FORM_FIELD_IDS.reason}
57+
error={getFieldErrorMessage(fieldErrors, 'reason')}
58+
>
59+
<Controller
60+
name="reason"
61+
control={control}
62+
defaultValue=""
63+
render={({ field: { ref, ...field } }) => (
64+
<Textarea
65+
{...field}
66+
id={SCHEDULE_ACTION_RESUME_FORM_FIELD_IDS.reason}
67+
// @ts-expect-error - inputRef expects ref object while ref is a callback. It should support both.
68+
inputRef={ref}
69+
size="compact"
70+
onChange={(e) => {
71+
field.onChange(e.target.value);
72+
}}
73+
onBlur={field.onBlur}
74+
error={Boolean(getFieldErrorMessage(fieldErrors, 'reason'))}
75+
placeholder="Add reason"
76+
/>
77+
)}
78+
/>
79+
</DomainSchedulesHorizontalField>
80+
</>
81+
);
82+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { type z } from 'zod';
2+
3+
import { type ScheduleCatchUpPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleCatchUpPolicy';
4+
5+
import { type ScheduleActionFormProps } from '../schedule-actions.types';
6+
7+
import { type resumeScheduleFormSchema } from './schemas/resume-schedule-form-schema';
8+
9+
export type ResumeScheduleFormData = z.infer<typeof resumeScheduleFormSchema>;
10+
11+
export type ResumeScheduleSubmissionData = {
12+
reason?: string;
13+
catchUpPolicy?: ScheduleCatchUpPolicy;
14+
};
15+
16+
export type Props = Pick<
17+
ScheduleActionFormProps<ResumeScheduleFormData>,
18+
'fieldErrors' | 'control'
19+
>;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from 'zod';
2+
3+
import { SCHEDULE_CATCH_UP_POLICIES } from '@/route-handlers/create-schedule/create-schedule.constants';
4+
5+
import { USE_SCHEDULE_CATCH_UP_POLICY } from '../schedule-action-resume-form.constants';
6+
7+
export const resumeScheduleFormSchema = z.object({
8+
reason: z.string().trim().optional(),
9+
catchUpPolicy: z
10+
.union([
11+
z.literal(USE_SCHEDULE_CATCH_UP_POLICY),
12+
z.enum(SCHEDULE_CATCH_UP_POLICIES),
13+
])
14+
.optional(),
15+
});

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ describe(ScheduleActionsModalContent.name, () => {
142142
);
143143
});
144144
});
145+
146+
it('calls resume API with default submission when confirmed', async () => {
147+
const { user, mockOnClose, getLatestRequestBody, waitForRequest } = setup({
148+
actionConfig: mockScheduleActionsConfig[1],
149+
});
150+
151+
await user.click(
152+
await screen.findByRole('button', { name: 'Mock resume schedule' })
153+
);
154+
155+
await waitForRequest();
156+
expect(getLatestRequestBody()).toEqual({});
157+
158+
await waitFor(() => {
159+
expect(mockEnqueue).toHaveBeenCalledWith(
160+
expect.objectContaining({
161+
message: 'Mock resume notification',
162+
})
163+
);
164+
});
165+
expect(mockOnClose).toHaveBeenCalled();
166+
});
145167
});
146168

147169
function setup({

0 commit comments

Comments
 (0)