Skip to content

Commit bdcdc7c

Browse files
authored
feat(schedule-actions): add confirmation modal and mutation flow (#1426)
## Summary * Adds the schedule-actions modal shell with confirmation flow, mutation, snackbar feedback, and query invalidation. * Mirrors the workflow-actions modal pattern: form slot, optional banner, submit/cancel, and error banner on API failure.
1 parent 3486067 commit bdcdc7c

9 files changed

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

0 commit comments

Comments
 (0)