Skip to content

Commit 12f312e

Browse files
add bulk finalize functionality to the UI (#7264)
1 parent fbbb1d3 commit 12f312e

File tree

6 files changed

+222
-7
lines changed

6 files changed

+222
-7
lines changed

changelog/7264.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
type: Added
2+
description: Added bulk finalize action to privacy request page
3+
pr: 7264

clients/admin-ui/src/features/privacy-requests/dashboard/hooks/usePrivacyRequestBulkActions.test.tsx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import { usePrivacyRequestBulkActions } from "./usePrivacyRequestBulkActions";
99
const mockBulkApproveRequest = jest.fn();
1010
const mockBulkDenyRequest = jest.fn();
1111
const mockBulkSoftDeleteRequest = jest.fn();
12+
const mockBulkFinalizeRequest = jest.fn();
1213
const mockOpenDenyPrivacyRequestModal = jest.fn();
1314

1415
jest.mock("../../privacy-requests.slice", () => ({
1516
useBulkApproveRequestMutation: () => [mockBulkApproveRequest],
1617
useBulkDenyRequestMutation: () => [mockBulkDenyRequest],
1718
useBulkSoftDeleteRequestMutation: () => [mockBulkSoftDeleteRequest],
19+
useBulkFinalizeRequestMutation: () => [mockBulkFinalizeRequest],
1820
}));
1921

2022
jest.mock("../../hooks/useDenyRequestModal", () => ({
@@ -45,6 +47,7 @@ jest.mock("fidesui", () => ({
4547
Checkmark: () => null,
4648
Close: () => null,
4749
TrashCan: () => null,
50+
Stamp: () => null,
4851
},
4952
}));
5053

@@ -65,6 +68,11 @@ describe("usePrivacyRequestBulkActions", () => {
6568
status: PrivacyRequestStatus.COMPLETE,
6669
} as PrivacyRequestResponse;
6770

71+
const requiresFinalizationRequest: PrivacyRequestResponse = {
72+
id: "4",
73+
status: PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION,
74+
} as PrivacyRequestResponse;
75+
6876
const mockRequests: PrivacyRequestResponse[] = [
6977
pendingRequest1,
7078
completeRequest,
@@ -85,7 +93,7 @@ describe("usePrivacyRequestBulkActions", () => {
8593

8694
const menuItems = result.current.bulkActionMenuItems;
8795

88-
expect(menuItems).toHaveLength(4);
96+
expect(menuItems).toHaveLength(5);
8997
expect(menuItems[0]).toMatchObject({
9098
key: BulkActionType.APPROVE,
9199
label: "Approve",
@@ -96,14 +104,36 @@ describe("usePrivacyRequestBulkActions", () => {
96104
label: "Deny",
97105
disabled: false,
98106
});
99-
expect(menuItems[3]).toMatchObject({
107+
expect(menuItems[2]).toMatchObject({
108+
key: BulkActionType.FINALIZE,
109+
label: "Finalize",
110+
disabled: true, // No requests in REQUIRES_MANUAL_FINALIZATION status
111+
});
112+
expect(menuItems[4]).toMatchObject({
100113
key: BulkActionType.DELETE,
101114
label: "Delete",
102115
disabled: false,
103116
danger: true,
104117
});
105118
});
106119

120+
it("enables finalize action when requests require manual finalization", () => {
121+
const { result } = renderHook(() =>
122+
usePrivacyRequestBulkActions({
123+
requests: [requiresFinalizationRequest],
124+
selectedIds: ["4"],
125+
}),
126+
);
127+
128+
const menuItems = result.current.bulkActionMenuItems;
129+
130+
expect(menuItems[2]).toMatchObject({
131+
key: BulkActionType.FINALIZE,
132+
label: "Finalize",
133+
disabled: false,
134+
});
135+
});
136+
107137
describe("Bulk actions", () => {
108138
it("approve: shows confirmation modal and calls API successfully", async () => {
109139
mockBulkApproveRequest.mockResolvedValue({
@@ -162,7 +192,7 @@ describe("usePrivacyRequestBulkActions", () => {
162192
}),
163193
);
164194

165-
const deleteMenuItem = result.current.bulkActionMenuItems[3] as Extract<
195+
const deleteMenuItem = result.current.bulkActionMenuItems[4] as Extract<
166196
MenuProps["items"],
167197
Array<any>
168198
>[number] & { onClick?: (e: any) => void };
@@ -188,6 +218,48 @@ describe("usePrivacyRequestBulkActions", () => {
188218
});
189219
});
190220

221+
it("finalize: shows confirmation modal and calls API successfully", async () => {
222+
mockBulkFinalizeRequest.mockResolvedValue({
223+
data: {
224+
succeeded: [{ id: "4" }],
225+
failed: [],
226+
},
227+
});
228+
229+
const { result } = renderHook(() =>
230+
usePrivacyRequestBulkActions({
231+
requests: [requiresFinalizationRequest],
232+
selectedIds: ["4"],
233+
}),
234+
);
235+
236+
const finalizeMenuItem = result.current.bulkActionMenuItems[2] as Extract<
237+
MenuProps["items"],
238+
Array<any>
239+
>[number] & { onClick?: (e: any) => void };
240+
241+
await act(async () => {
242+
finalizeMenuItem?.onClick?.({} as any);
243+
});
244+
245+
expect(mockModalApi.confirm).toHaveBeenCalledWith(
246+
expect.objectContaining({
247+
title: "Finalize privacy requests",
248+
content:
249+
"You are about to finalize 1 privacy request. Are you sure you want to continue?",
250+
}),
251+
);
252+
253+
const confirmCall = mockModalApi.confirm.mock.calls[0][0];
254+
await act(async () => {
255+
await confirmCall.onOk();
256+
});
257+
258+
expect(mockBulkFinalizeRequest).toHaveBeenCalledWith({
259+
request_ids: ["4"],
260+
});
261+
});
262+
191263
it("deny: calls API with reason and passes warning message for partial support", async () => {
192264
mockOpenDenyPrivacyRequestModal.mockResolvedValue(
193265
"User requested withdrawal",

clients/admin-ui/src/features/privacy-requests/dashboard/hooks/usePrivacyRequestBulkActions.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useDenyPrivacyRequestModal } from "../../hooks/useDenyRequestModal";
1313
import {
1414
useBulkApproveRequestMutation,
1515
useBulkDenyRequestMutation,
16+
useBulkFinalizeRequestMutation,
1617
useBulkSoftDeleteRequestMutation,
1718
} from "../../privacy-requests.slice";
1819

@@ -25,6 +26,7 @@ const ACTION_PAST_TENSE: Record<BulkActionType, string> = {
2526
[BulkActionType.APPROVE]: "approved",
2627
[BulkActionType.DENY]: "denied",
2728
[BulkActionType.DELETE]: "deleted",
29+
[BulkActionType.FINALIZE]: "finalized",
2830
};
2931

3032
const ACTION_CONFIG: Record<
@@ -44,6 +46,10 @@ const ACTION_CONFIG: Record<
4446
verb: "delete",
4547
okType: "danger",
4648
},
49+
[BulkActionType.FINALIZE]: {
50+
title: "Finalize privacy requests",
51+
verb: "finalize",
52+
},
4753
};
4854

4955
const formatResultMessage = (
@@ -91,6 +97,7 @@ export const usePrivacyRequestBulkActions = ({
9197
// Mutation hooks for the actions
9298
const [bulkApproveRequest] = useBulkApproveRequestMutation();
9399
const [bulkDenyRequest] = useBulkDenyRequestMutation();
100+
const [bulkFinalizeRequest] = useBulkFinalizeRequestMutation();
94101
const [bulkSoftDeleteRequest] = useBulkSoftDeleteRequestMutation();
95102

96103
// Use the deny modal hook
@@ -172,6 +179,8 @@ export const usePrivacyRequestBulkActions = ({
172179
result = await bulkApproveRequest({ request_ids: requestIds });
173180
} else if (action === BulkActionType.DELETE) {
174181
result = await bulkSoftDeleteRequest({ request_ids: requestIds });
182+
} else if (action === BulkActionType.FINALIZE) {
183+
result = await bulkFinalizeRequest({ request_ids: requestIds });
175184
}
176185

177186
hideLoading();
@@ -182,10 +191,8 @@ export const usePrivacyRequestBulkActions = ({
182191
}
183192

184193
if ("error" in result) {
185-
const actionVerb =
186-
action === BulkActionType.APPROVE ? "approve" : "delete";
187194
messageApi.error(
188-
`Failed to ${actionVerb} requests. Please try again.`,
195+
`Failed to ${ACTION_CONFIG[action].verb} requests. Please try again.`,
189196
5,
190197
);
191198
} else if ("data" in result) {
@@ -207,6 +214,7 @@ export const usePrivacyRequestBulkActions = ({
207214
openDenyPrivacyRequestModal,
208215
bulkDenyRequest,
209216
bulkApproveRequest,
217+
bulkFinalizeRequest,
210218
bulkSoftDeleteRequest,
211219
messageApi,
212220
modalApi,
@@ -222,6 +230,10 @@ export const usePrivacyRequestBulkActions = ({
222230
BulkActionType.DENY,
223231
selectedRequests,
224232
);
233+
const canFinalize = isActionSupportedByRequests(
234+
BulkActionType.FINALIZE,
235+
selectedRequests,
236+
);
225237
const canDelete = isActionSupportedByRequests(
226238
BulkActionType.DELETE,
227239
selectedRequests,
@@ -242,6 +254,13 @@ export const usePrivacyRequestBulkActions = ({
242254
onClick: () => handleAction(BulkActionType.DENY),
243255
disabled: !canDeny,
244256
},
257+
{
258+
key: BulkActionType.FINALIZE,
259+
label: "Finalize",
260+
icon: <Icons.Stamp />,
261+
onClick: () => handleAction(BulkActionType.FINALIZE),
262+
disabled: !canFinalize,
263+
},
245264
{
246265
type: "divider",
247266
},
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { PrivacyRequestResponse, PrivacyRequestStatus } from "~/types/api";
2+
3+
import {
4+
BulkActionType,
5+
getAvailableActionsForRequest,
6+
isActionSupportedByRequests,
7+
} from "./helpers";
8+
9+
describe("helpers", () => {
10+
describe("BulkActionType", () => {
11+
it("should include FINALIZE action type", () => {
12+
expect(BulkActionType.FINALIZE).toBe("finalize");
13+
});
14+
});
15+
16+
describe("getAvailableActionsForRequest", () => {
17+
it("returns approve, deny, and delete for pending requests", () => {
18+
const request = {
19+
status: PrivacyRequestStatus.PENDING,
20+
} as PrivacyRequestResponse;
21+
22+
const actions = getAvailableActionsForRequest(request);
23+
24+
expect(actions).toContain(BulkActionType.APPROVE);
25+
expect(actions).toContain(BulkActionType.DENY);
26+
expect(actions).toContain(BulkActionType.DELETE);
27+
expect(actions).not.toContain(BulkActionType.FINALIZE);
28+
});
29+
30+
it("returns finalize and delete for requests requiring manual finalization", () => {
31+
const request = {
32+
status: PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION,
33+
} as PrivacyRequestResponse;
34+
35+
const actions = getAvailableActionsForRequest(request);
36+
37+
expect(actions).toContain(BulkActionType.FINALIZE);
38+
expect(actions).toContain(BulkActionType.DELETE);
39+
expect(actions).not.toContain(BulkActionType.APPROVE);
40+
expect(actions).not.toContain(BulkActionType.DENY);
41+
});
42+
43+
it("returns only delete for complete requests", () => {
44+
const request = {
45+
status: PrivacyRequestStatus.COMPLETE,
46+
} as PrivacyRequestResponse;
47+
48+
const actions = getAvailableActionsForRequest(request);
49+
50+
expect(actions).toEqual([BulkActionType.DELETE]);
51+
});
52+
});
53+
54+
describe("isActionSupportedByRequests", () => {
55+
it("returns true if at least one request supports finalize", () => {
56+
const requests = [
57+
{ status: PrivacyRequestStatus.PENDING } as PrivacyRequestResponse,
58+
{
59+
status: PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION,
60+
} as PrivacyRequestResponse,
61+
];
62+
63+
expect(
64+
isActionSupportedByRequests(BulkActionType.FINALIZE, requests),
65+
).toBe(true);
66+
});
67+
68+
it("returns false if no requests support finalize", () => {
69+
const requests = [
70+
{ status: PrivacyRequestStatus.PENDING } as PrivacyRequestResponse,
71+
{ status: PrivacyRequestStatus.COMPLETE } as PrivacyRequestResponse,
72+
];
73+
74+
expect(
75+
isActionSupportedByRequests(BulkActionType.FINALIZE, requests),
76+
).toBe(false);
77+
});
78+
79+
it("returns true for approve when pending requests exist", () => {
80+
const requests = [
81+
{ status: PrivacyRequestStatus.PENDING } as PrivacyRequestResponse,
82+
{
83+
status: PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION,
84+
} as PrivacyRequestResponse,
85+
];
86+
87+
expect(
88+
isActionSupportedByRequests(BulkActionType.APPROVE, requests),
89+
).toBe(true);
90+
});
91+
92+
it("returns true for delete for any request status", () => {
93+
const requests = [
94+
{ status: PrivacyRequestStatus.COMPLETE } as PrivacyRequestResponse,
95+
{
96+
status: PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION,
97+
} as PrivacyRequestResponse,
98+
];
99+
100+
expect(isActionSupportedByRequests(BulkActionType.DELETE, requests)).toBe(
101+
true,
102+
);
103+
});
104+
});
105+
});

clients/admin-ui/src/features/privacy-requests/helpers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum BulkActionType {
77
APPROVE = "approve",
88
DENY = "deny",
99
DELETE = "delete",
10+
FINALIZE = "finalize",
1011
}
1112

1213
/**
@@ -35,7 +36,10 @@ const AVAILABLE_ACTIONS_BY_STATUS: Record<
3536
[PrivacyRequestStatus.COMPLETE]: [BulkActionType.DELETE],
3637
[PrivacyRequestStatus.PAUSED]: [BulkActionType.DELETE],
3738
[PrivacyRequestStatus.AWAITING_EMAIL_SEND]: [BulkActionType.DELETE],
38-
[PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION]: [BulkActionType.DELETE],
39+
[PrivacyRequestStatus.REQUIRES_MANUAL_FINALIZATION]: [
40+
BulkActionType.FINALIZE,
41+
BulkActionType.DELETE,
42+
],
3943
[PrivacyRequestStatus.CANCELED]: [BulkActionType.DELETE],
4044
[PrivacyRequestStatus.ERROR]: [BulkActionType.DELETE],
4145
} as const;

clients/admin-ui/src/features/privacy-requests/privacy-requests.slice.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,17 @@ export const privacyRequestApi = baseApi.injectEndpoints({
375375
}),
376376
invalidatesTags: ["Request"],
377377
}),
378+
bulkFinalizeRequest: build.mutation<
379+
{ succeeded: string[]; failed: any[] },
380+
{ request_ids: string[] }
381+
>({
382+
query: (body) => ({
383+
url: `privacy-request/bulk/finalize`,
384+
method: "POST",
385+
body,
386+
}),
387+
invalidatesTags: ["Request"],
388+
}),
378389
getAllPrivacyRequests: build.query<
379390
PrivacyRequestResponse,
380391
Partial<PrivacyRequestParams>
@@ -610,6 +621,7 @@ export const {
610621
useBulkDenyRequestMutation,
611622
useBulkRetryMutation,
612623
useBulkSoftDeleteRequestMutation,
624+
useBulkFinalizeRequestMutation,
613625
useDenyRequestMutation,
614626
useSoftDeleteRequestMutation,
615627
useGetAllPrivacyRequestsQuery,

0 commit comments

Comments
 (0)