Skip to content

Commit 058bc54

Browse files
feat(schedule-actions): add confirmation modal and mutation flow (PR2)
Add schedule-actions modal shell and modal content with POST submission, cache invalidation, snackbar, and error handling. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7ff39fa commit 058bc54

8 files changed

Lines changed: 474 additions & 0 deletions
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { HttpResponse } from 'msw';
2+
3+
import { render, screen, userEvent, waitFor } from '@/test-utils/rtl';
4+
5+
import { type PauseScheduleResponse } from '@/route-handlers/pause-schedule/pause-schedule.types';
6+
7+
import { mockScheduleActionsConfig } from '../../__fixtures__/schedule-actions-config';
8+
import { type ScheduleAction } from '../../schedule-actions.types';
9+
import ScheduleActionsModalContent from '../schedule-actions-modal-content';
10+
11+
const mockScheduleParams = {
12+
domain: 'mock-domain',
13+
cluster: 'mock-cluster',
14+
scheduleId: 'mock-schedule-id',
15+
};
16+
17+
const mockEnqueue = jest.fn();
18+
const mockDequeue = jest.fn();
19+
jest.mock('baseui/snackbar', () => ({
20+
...jest.requireActual('baseui/snackbar'),
21+
useSnackbar: () => ({
22+
enqueue: mockEnqueue,
23+
dequeue: mockDequeue,
24+
}),
25+
}));
26+
27+
const mockPush = jest.fn();
28+
jest.mock('next/navigation', () => ({
29+
...jest.requireActual('next/navigation'),
30+
useRouter: () => ({ push: mockPush }),
31+
}));
32+
33+
describe(ScheduleActionsModalContent.name, () => {
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
});
37+
38+
it('renders the modal content as expected', async () => {
39+
setup({});
40+
41+
expect(await screen.findAllByText('Mock pause schedule')).toHaveLength(2);
42+
expect(
43+
screen.getByText('Mock modal text to pause a schedule')
44+
).toBeInTheDocument();
45+
expect(
46+
screen.getByRole('button', { name: 'Mock pause schedule' })
47+
).toBeInTheDocument();
48+
});
49+
50+
it('calls onCloseModal when the Cancel button is clicked', async () => {
51+
const { user, mockOnClose } = setup({});
52+
53+
await user.click(await screen.findByText('Cancel'));
54+
expect(mockOnClose).toHaveBeenCalled();
55+
});
56+
57+
it('calls pause API, sends toast, and closes modal when confirmed', async () => {
58+
const { user, mockOnClose, getLatestRequestBody, waitForRequest } =
59+
setup({});
60+
61+
await user.click(
62+
await screen.findByRole('button', { name: 'Mock pause schedule' })
63+
);
64+
65+
await waitForRequest();
66+
expect(getLatestRequestBody()).toEqual({ reason: 'Mock pause reason' });
67+
68+
await waitFor(() => {
69+
expect(mockEnqueue).toHaveBeenCalledWith(
70+
expect.objectContaining({
71+
message: 'Mock pause notification',
72+
})
73+
);
74+
});
75+
expect(mockOnClose).toHaveBeenCalled();
76+
});
77+
78+
it('displays banner when the action fails', async () => {
79+
const { user, mockOnClose } = setup({ error: true });
80+
81+
await user.click(
82+
await screen.findByRole('button', { name: 'Mock pause schedule' })
83+
);
84+
85+
await waitFor(() => {
86+
expect(screen.getByText('Failed to pause schedule')).toBeInTheDocument();
87+
});
88+
expect(mockOnClose).not.toHaveBeenCalled();
89+
});
90+
});
91+
92+
function setup({
93+
error,
94+
actionConfig,
95+
}: {
96+
error?: boolean;
97+
actionConfig?: ScheduleAction<any, any, any>;
98+
}) {
99+
const user = userEvent.setup();
100+
const mockOnClose = jest.fn();
101+
let latestRequestBody: unknown = null;
102+
let requestPromiseResolve: (value: unknown) => void = () => undefined;
103+
const requestPromise = new Promise((resolve) => {
104+
requestPromiseResolve = resolve;
105+
});
106+
107+
render(
108+
<ScheduleActionsModalContent
109+
action={actionConfig ?? mockScheduleActionsConfig[0]}
110+
params={{ ...mockScheduleParams }}
111+
onCloseModal={mockOnClose}
112+
/>,
113+
{
114+
endpointsMocks: [
115+
{
116+
path: '/api/domains/:domain/:cluster/schedules/:scheduleId/:action',
117+
httpMethod: 'POST',
118+
mockOnce: false,
119+
httpResolver: async ({ request }) => {
120+
const text = await request.text();
121+
latestRequestBody = text ? JSON.parse(text) : null;
122+
requestPromiseResolve(null);
123+
124+
if (error) {
125+
return HttpResponse.json(
126+
{ message: 'Failed to pause schedule' },
127+
{ status: 500 }
128+
);
129+
}
130+
131+
return HttpResponse.json({} satisfies PauseScheduleResponse);
132+
},
133+
},
134+
],
135+
}
136+
);
137+
138+
return {
139+
user,
140+
mockOnClose,
141+
getLatestRequestBody: () => latestRequestBody,
142+
waitForRequest: () => requestPromise,
143+
};
144+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type Theme, withStyle } from 'baseui';
2+
import { type BannerOverrides } from 'baseui/banner';
3+
import { StyledLink } from 'baseui/link';
4+
import { ModalBody, ModalFooter, ModalHeader } from 'baseui/modal';
5+
6+
export const styled = {
7+
ModalHeader: withStyle(ModalHeader, ({ $theme }: { $theme: Theme }) => ({
8+
marginTop: $theme.sizing.scale850,
9+
})),
10+
ModalBody: withStyle(ModalBody, ({ $theme }: { $theme: Theme }) => ({
11+
display: 'flex',
12+
flexDirection: 'column',
13+
rowGap: $theme.sizing.scale600,
14+
marginBottom: $theme.sizing.scale800,
15+
})),
16+
ModalFooter: withStyle(ModalFooter, {
17+
display: 'flex',
18+
justifyContent: 'space-between',
19+
}),
20+
Link: withStyle(StyledLink, ({ $theme }: { $theme: Theme }) => ({
21+
alignSelf: 'start',
22+
...$theme.typography.LabelSmall,
23+
display: 'flex',
24+
alignItems: 'center',
25+
columnGap: $theme.sizing.scale100,
26+
})),
27+
};
28+
29+
export const overrides = {
30+
banner: {
31+
Root: {
32+
style: {
33+
marginLeft: 0,
34+
marginRight: 0,
35+
},
36+
},
37+
} satisfies BannerOverrides,
38+
};
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { useMutation, useQueryClient } from '@tanstack/react-query';
3+
import { Banner, HIERARCHY, KIND as BANNER_KIND } from 'baseui/banner';
4+
import { KIND as BUTTON_KIND, SIZE } from 'baseui/button';
5+
import { ModalButton } from 'baseui/modal';
6+
import { useSnackbar } from 'baseui/snackbar';
7+
import { useRouter } from 'next/navigation';
8+
import { type DefaultValues, type FieldValues, useForm } from 'react-hook-form';
9+
import { MdCheckCircle, MdErrorOutline, MdOpenInNew } from 'react-icons/md';
10+
11+
import request from '@/utils/request';
12+
import { type RequestError } from '@/utils/request/request-error';
13+
14+
import { type ScheduleActionInput } from '../schedule-actions.types';
15+
16+
import { overrides, styled } from './schedule-actions-modal-content.styles';
17+
import { type Props } from './schedule-actions-modal-content.types';
18+
19+
export default function ScheduleActionsModalContent<
20+
Result,
21+
FormData,
22+
SubmissionData,
23+
>({
24+
action,
25+
params,
26+
onCloseModal,
27+
initialFormValues,
28+
}: Props<Result, FormData, SubmissionData>) {
29+
const queryClient = useQueryClient();
30+
const router = useRouter();
31+
const { enqueue, dequeue } = useSnackbar();
32+
33+
type OptionalFormData = FormData extends FieldValues ? FormData : FieldValues;
34+
35+
const {
36+
handleSubmit,
37+
formState: { errors: validationErrors, isSubmitting },
38+
control,
39+
watch,
40+
clearErrors,
41+
trigger,
42+
} = useForm<OptionalFormData>({
43+
resolver: action.modal.formSchema
44+
? zodResolver(action.modal.formSchema)
45+
: undefined,
46+
defaultValues: initialFormValues as DefaultValues<OptionalFormData>,
47+
});
48+
49+
const { mutate, isPending, error } = useMutation<
50+
Result,
51+
RequestError,
52+
ScheduleActionInput<SubmissionData>
53+
>(
54+
{
55+
mutationFn: ({
56+
domain,
57+
cluster,
58+
scheduleId,
59+
submissionData,
60+
}: ScheduleActionInput<SubmissionData>) =>
61+
request(action.apiRoute({ domain, cluster, scheduleId }), {
62+
method: 'POST',
63+
body: JSON.stringify(submissionData ?? {}),
64+
}).then((res) => res.json() as Result),
65+
onSuccess: (result, mutationParams) => {
66+
queryClient.invalidateQueries({
67+
queryKey: ['describeSchedule', params],
68+
});
69+
70+
action.onSuccess?.({ queryClient, params, router });
71+
72+
onCloseModal();
73+
enqueue({
74+
message: action.renderSuccessMessage?.({
75+
result,
76+
inputParams: mutationParams,
77+
onDismissMessage: () => dequeue(),
78+
}),
79+
startEnhancer: MdCheckCircle,
80+
actionMessage: 'OK',
81+
actionOnClick: () => dequeue(),
82+
});
83+
},
84+
},
85+
queryClient
86+
);
87+
88+
const onSubmit = (data: OptionalFormData) => {
89+
mutate({
90+
...params,
91+
submissionData: action.modal.withForm
92+
? action.modal.transformFormDataToSubmission(data as FormData)
93+
: (action.getConfirmSubmissionData?.() ??
94+
(undefined as SubmissionData)),
95+
});
96+
};
97+
98+
const modalText = Array.isArray(action.modal.text) ? (
99+
action.modal.text.map((text, index) => <p key={index}>{text}</p>)
100+
) : (
101+
<p>{action.modal.text}</p>
102+
);
103+
104+
const Form = action.modal.form;
105+
const isSubmitDisabled = Object.keys(validationErrors).length > 0;
106+
107+
return (
108+
<>
109+
<styled.ModalHeader>{`${action.label} schedule`}</styled.ModalHeader>
110+
<form onSubmit={handleSubmit(onSubmit)}>
111+
<styled.ModalBody>
112+
{modalText}
113+
{action.modal.docsLink && (
114+
<styled.Link
115+
href={action.modal.docsLink.href}
116+
target="_blank"
117+
rel="noreferrer"
118+
>
119+
{action.modal.docsLink.text}
120+
<MdOpenInNew />
121+
</styled.Link>
122+
)}
123+
{action.modal.withForm && Form && (
124+
<Form
125+
formData={watch()}
126+
fieldErrors={validationErrors}
127+
clearErrors={clearErrors}
128+
control={control}
129+
trigger={trigger}
130+
cluster={params.cluster}
131+
domain={params.domain}
132+
scheduleId={params.scheduleId}
133+
/>
134+
)}
135+
{error && (
136+
<Banner
137+
hierarchy={HIERARCHY.low}
138+
kind={BANNER_KIND.negative}
139+
overrides={overrides.banner}
140+
artwork={{
141+
icon: MdErrorOutline,
142+
}}
143+
>
144+
{error.message}
145+
</Banner>
146+
)}
147+
</styled.ModalBody>
148+
<styled.ModalFooter>
149+
<ModalButton
150+
autoFocus={!action.modal.withForm}
151+
size={SIZE.compact}
152+
type="button"
153+
kind={BUTTON_KIND.secondary}
154+
onClick={onCloseModal}
155+
>
156+
Cancel
157+
</ModalButton>
158+
<ModalButton
159+
size={SIZE.compact}
160+
kind={BUTTON_KIND.primary}
161+
type="submit"
162+
isLoading={isPending || isSubmitting}
163+
disabled={isSubmitDisabled}
164+
>
165+
{`${action.label} schedule`}
166+
</ModalButton>
167+
</styled.ModalFooter>
168+
</form>
169+
</>
170+
);
171+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { type DefaultValues } from 'react-hook-form';
2+
3+
import {
4+
type ScheduleAction,
5+
type ScheduleActionInputParams,
6+
} from '../schedule-actions.types';
7+
8+
export type Props<Result, FormData, SubmissionData> = {
9+
action: ScheduleAction<Result, FormData, SubmissionData>;
10+
params: ScheduleActionInputParams;
11+
onCloseModal: () => void;
12+
initialFormValues?: DefaultValues<FormData>;
13+
};

0 commit comments

Comments
 (0)