@redacto.io/consent-sdk-react 9.1.0 → 9.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -9
- package/CHANGELOG.md +12 -0
- package/dist/index.js +2 -4
- package/dist/index.mjs +2 -4
- package/dist/privacy-center.d.mts +56 -2
- package/dist/privacy-center.d.ts +56 -2
- package/dist/privacy-center.js +4840 -1874
- package/dist/privacy-center.mjs +5124 -2158
- package/package.json +1 -1
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +2 -4
- package/src/RedactoNoticeConsent/api/types.ts +1 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +3 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
- package/src/RedactoPrivacyCenter/api/client.ts +79 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +19 -13
- package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +25 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +39 -30
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +2 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +10 -3
- package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +21 -6
- package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +12 -0
- package/src/RedactoPrivacyCenter/components/Form.tsx +843 -162
- package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +284 -71
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
- package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
- package/src/RedactoPrivacyCenter/components/types.ts +26 -2
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
- package/src/RedactoPrivacyCenter/lib/api.ts +95 -1
- package/src/RedactoPrivacyCenter/lib/constants.ts +17 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +115 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +46 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +40 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +40 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
- package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +3 -1
- package/tests/Form.test.tsx +1170 -0
- package/tests/ProductSelectionSection.test.tsx +293 -0
- package/tests/RequestSubmit.test.tsx +295 -0
|
@@ -8,10 +8,14 @@ import { useTranslation } from 'react-i18next';
|
|
|
8
8
|
import { AiOutlineClose } from 'react-icons/ai';
|
|
9
9
|
import { PiUploadBold } from 'react-icons/pi';
|
|
10
10
|
import { toast } from '../utils/toast';
|
|
11
|
-
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCTextarea } from '../ui';
|
|
12
|
-
import {
|
|
11
|
+
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
|
|
12
|
+
import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
|
|
13
13
|
import { pcStyles } from '../styles/pcStyles';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createCaseRequest,
|
|
16
|
+
createCaseRequestUnsafe,
|
|
17
|
+
getFormData,
|
|
18
|
+
} from '../lib/api';
|
|
15
19
|
import { ensureValidToken } from '../lib/utils';
|
|
16
20
|
import { useAuth } from '../context/AuthContext';
|
|
17
21
|
import { useBaseUrl } from '../context/BaseUrlContext';
|
|
@@ -23,8 +27,11 @@ import { useOrganization } from '../context/OrganizationContext';
|
|
|
23
27
|
import i18n from '../lib/i18n';
|
|
24
28
|
import { getLangParam } from '../lib/useCurrentLanguage';
|
|
25
29
|
import type {
|
|
30
|
+
BatchCaseSubmitResult,
|
|
31
|
+
CreateCaseResponse,
|
|
26
32
|
DataElement,
|
|
27
33
|
FormData,
|
|
34
|
+
FormProductGroup,
|
|
28
35
|
GrievanceOptions,
|
|
29
36
|
GrievanceTypeElement,
|
|
30
37
|
GrievanceValue,
|
|
@@ -38,14 +45,74 @@ import { RequestTypeEnum } from '../lib/types';
|
|
|
38
45
|
import CorrectionDataSection from './DataRequest/CorrectionDataSection';
|
|
39
46
|
import GrievanceDataSection from './DataRequest/GrievanceDataSection';
|
|
40
47
|
import NominationDataSection from './DataRequest/NominationDataSection';
|
|
48
|
+
import {
|
|
49
|
+
ProductSelectionSection,
|
|
50
|
+
productGroupKey,
|
|
51
|
+
} from './DataRequest/ProductSelectionSection';
|
|
41
52
|
import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
|
|
42
53
|
import RequestSubmit from './RequestSubmit';
|
|
43
54
|
import type { CaseHistoryProps } from './types';
|
|
44
|
-
import {
|
|
45
|
-
import { MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL } from './constant';
|
|
55
|
+
import { FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
|
|
46
56
|
|
|
47
57
|
const getVar = (varName: string): string => `var(${varName})`;
|
|
48
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Narrow shape of the `case_id`/`uuid` fields extracted from a
|
|
61
|
+
* `CreateCaseResponse` (or its unwrapped privacy-center variant). Both
|
|
62
|
+
* are stringly returned — empty strings mean "not present on the
|
|
63
|
+
* response" rather than `null`, which keeps the call sites simple.
|
|
64
|
+
*/
|
|
65
|
+
type ExtractedCaseRef = { uuid: string; case_id: string };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The two response shapes `extractCaseId` walks before resolving an
|
|
69
|
+
* `ExtractedCaseRef`. Privacy-center returns the unwrapped detail;
|
|
70
|
+
* case-management wraps it in `{ code, status, detail }`.
|
|
71
|
+
*/
|
|
72
|
+
type CaseIdContainer = { case_id?: string; uuid?: string };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Request types that engage the Phase 2 product-step + per-product picker.
|
|
76
|
+
* Nomination and grievance are org-level and skip the product step.
|
|
77
|
+
*
|
|
78
|
+
* ACCESS/ERASURE drive a per-product `PurposeSelectionSection`; CORRECTION
|
|
79
|
+
* drives a per-product `CorrectionDataSection` whose `dataElements` are
|
|
80
|
+
* scoped to the selected group (dedup within product, not across products).
|
|
81
|
+
*/
|
|
82
|
+
const GROUPED_REQUEST_TYPES: ReadonlySet<RequestTypeEnum> = new Set([
|
|
83
|
+
RequestTypeEnum.ACCESS,
|
|
84
|
+
RequestTypeEnum.ERASURE,
|
|
85
|
+
RequestTypeEnum.CORRECTION,
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const isGroupedRequestType = (
|
|
89
|
+
rt: RequestTypeEnum | null,
|
|
90
|
+
): rt is RequestTypeEnum => rt !== null && GROUPED_REQUEST_TYPES.has(rt);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pull the `case_id` out of a `createCaseRequest` response, handling both
|
|
94
|
+
* the privacy-center shape (unwrapped detail) and the case-management shape
|
|
95
|
+
* (wrapped { code, status, detail }).
|
|
96
|
+
*/
|
|
97
|
+
const extractCaseId = (
|
|
98
|
+
response: CreateCaseResponse | null,
|
|
99
|
+
): ExtractedCaseRef | null => {
|
|
100
|
+
if (!response) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if ('detail' in response && response.detail) {
|
|
104
|
+
const detail = response.detail as CaseIdContainer;
|
|
105
|
+
if (detail.case_id || detail.uuid) {
|
|
106
|
+
return { case_id: detail.case_id ?? '', uuid: detail.uuid ?? '' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const direct = response as unknown as CaseIdContainer;
|
|
110
|
+
if (direct.case_id || direct.uuid) {
|
|
111
|
+
return { case_id: direct.case_id ?? '', uuid: direct.uuid ?? '' };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
|
|
49
116
|
export default function Form({
|
|
50
117
|
onCreateRequest,
|
|
51
118
|
isPopup = false,
|
|
@@ -93,6 +160,18 @@ export default function Form({
|
|
|
93
160
|
});
|
|
94
161
|
const [erasurePurposes, setErasurePurposes] = useState<Purpose[]>([]);
|
|
95
162
|
const [accessPurposes, setAccessPurposes] = useState<Purpose[]>([]);
|
|
163
|
+
// Phase 2 of TECH-3712. Set of product-group keys the user has selected on
|
|
164
|
+
// the new product-cards step. Keyed by `productGroupKey(group)` so a single
|
|
165
|
+
// product reached both directly and via a nominator stays distinct.
|
|
166
|
+
const [selectedProductKey, setSelectedProductKey] = useState<string | null>(
|
|
167
|
+
null,
|
|
168
|
+
);
|
|
169
|
+
// Phase 2 of TECH-3712. Per-product purpose state keyed by
|
|
170
|
+
// `productGroupKey(group)`. Each selected group keeps its own Purpose[]
|
|
171
|
+
// for the existing PurposeSelectionSection to drive.
|
|
172
|
+
const [purposesByGroupKey, setPurposesByGroupKey] = useState<
|
|
173
|
+
Map<string, Purpose[]>
|
|
174
|
+
>(new Map());
|
|
96
175
|
const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
|
|
97
176
|
string[]
|
|
98
177
|
>([]);
|
|
@@ -102,7 +181,29 @@ export default function Form({
|
|
|
102
181
|
const [correctionValues, setCorrectionValues] = useState<
|
|
103
182
|
Record<string, { current: string; updated: string }>
|
|
104
183
|
>({});
|
|
105
|
-
|
|
184
|
+
// TECH-3712 (correction grouped path). Per-product correction selections
|
|
185
|
+
// keyed by `productGroupKey(group)`. Parallel to `purposesByGroupKey`
|
|
186
|
+
// for ACCESS/ERASURE — each selected group keeps its own list of chosen
|
|
187
|
+
// data-element names and the {current, updated} value pairs for them.
|
|
188
|
+
const [correctionElementsByGroupKey, setCorrectionElementsByGroupKey] =
|
|
189
|
+
useState<Map<string, string[]>>(new Map());
|
|
190
|
+
const [correctionValuesByGroupKey, setCorrectionValuesByGroupKey] = useState<
|
|
191
|
+
Map<string, Record<string, { current: string; updated: string }>>
|
|
192
|
+
>(new Map());
|
|
193
|
+
// P2-SDK-4 of TECH-3712. Holds the aggregated batch result that
|
|
194
|
+
// `RequestSubmit` renders on the success step. Both paths populate this:
|
|
195
|
+
// - Grouped DSAR: directly from `submitBatchCases`.
|
|
196
|
+
// - Legacy single-case: a synthetic single-entry result.
|
|
197
|
+
const [batchResult, setBatchResult] = useState<BatchCaseSubmitResult | null>(
|
|
198
|
+
null,
|
|
199
|
+
);
|
|
200
|
+
// P2-SDK-4 of TECH-3712. Product-group keys currently mid-retry. Drives
|
|
201
|
+
// the per-row spinner state on the failure list. Keyed by
|
|
202
|
+
// `productGroupKey(group)` (not `product_uuid`) so a product picked both
|
|
203
|
+
// directly and via a nominator can be retried independently.
|
|
204
|
+
const [retryingGroupKeys, setRetryingGroupKeys] = useState<Set<string>>(
|
|
205
|
+
new Set(),
|
|
206
|
+
);
|
|
106
207
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
107
208
|
// Store the original English form data for submission — translations are display-only
|
|
108
209
|
const englishDataRef = useRef<FormData | null>(null);
|
|
@@ -134,10 +235,6 @@ export default function Form({
|
|
|
134
235
|
const [isTranslating, setIsTranslating] = useState(false);
|
|
135
236
|
const [isRequestDetailsExpanded, setIsRequestDetailsExpanded] =
|
|
136
237
|
useState(true);
|
|
137
|
-
const [isErasureConsentModalOpen, setIsErasureConsentModalOpen] =
|
|
138
|
-
useState(false);
|
|
139
|
-
const [revokeConsentOnFulfilment, setRevokeConsentOnFulfilment] =
|
|
140
|
-
useState(false);
|
|
141
238
|
|
|
142
239
|
const resetFileInput = () => {
|
|
143
240
|
if (fileInputRef.current) {
|
|
@@ -145,6 +242,122 @@ export default function Form({
|
|
|
145
242
|
}
|
|
146
243
|
};
|
|
147
244
|
|
|
245
|
+
// Phase 2 of TECH-3712. Backwards-compat guard: the new grouped flow only
|
|
246
|
+
// engages when the backend ships the `direct_groups` field (Phase 1). Older
|
|
247
|
+
// backends fall through to the legacy flat-purposes render below.
|
|
248
|
+
const useGroupedRender = useMemo<boolean>(
|
|
249
|
+
() => Array.isArray(data?.direct_groups),
|
|
250
|
+
[data],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Phase 2 of TECH-3712. Surface a one-time warning per data load when we
|
|
254
|
+
// fall back to the legacy flat-purposes render. Helps customer developers
|
|
255
|
+
// notice their backend hasn't been upgraded to Phase 1 yet (the form still
|
|
256
|
+
// works, but the new product-scoped DSAR UX is unavailable).
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (data && !useGroupedRender) {
|
|
259
|
+
console.warn(
|
|
260
|
+
'[consent-sdk] Backend response lacks direct_groups; ' +
|
|
261
|
+
'falling back to legacy flat-purposes render. ' +
|
|
262
|
+
'Update consent-server to Phase 1 to enable product-scoped DSAR.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}, [data, useGroupedRender]);
|
|
266
|
+
|
|
267
|
+
// Phase 2 of TECH-3712. Union of direct + nominated product groups used to
|
|
268
|
+
// drive both the cards step and the per-product purpose-picker step.
|
|
269
|
+
const allGroups = useMemo<FormProductGroup[]>(() => {
|
|
270
|
+
if (!useGroupedRender || !data) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
return [...(data.direct_groups ?? []), ...(data.nominated_groups ?? [])];
|
|
274
|
+
}, [data, useGroupedRender]);
|
|
275
|
+
|
|
276
|
+
// TECH-3712 UX: when the DP has exactly one consent group total
|
|
277
|
+
// (direct + nominated combined), the product-selection step is friction —
|
|
278
|
+
// there's nothing to choose. Skip rendering ProductSelectionSection
|
|
279
|
+
// entirely and auto-select the lone group below so step 3 (per-product
|
|
280
|
+
// purpose picker) renders without an intermediate card. With zero groups
|
|
281
|
+
// we still render the section so the empty-state message is visible.
|
|
282
|
+
const totalGroups = useMemo<number>(
|
|
283
|
+
() =>
|
|
284
|
+
(data?.direct_groups?.length ?? 0) +
|
|
285
|
+
(data?.nominated_groups?.length ?? 0),
|
|
286
|
+
[data],
|
|
287
|
+
);
|
|
288
|
+
const skipProductStep = totalGroups === 1;
|
|
289
|
+
|
|
290
|
+
// Phase 2 of TECH-3712. Subset of groups currently checked on the cards
|
|
291
|
+
// step — drives which `PurposeSelectionSection`s render and which cases
|
|
292
|
+
// get POSTed on submit.
|
|
293
|
+
const selectedGroups = useMemo<FormProductGroup[]>(() => {
|
|
294
|
+
if (selectedProductKey) {
|
|
295
|
+
const group = allGroups.find(
|
|
296
|
+
(g) => productGroupKey(g) === selectedProductKey,
|
|
297
|
+
);
|
|
298
|
+
if (group) {
|
|
299
|
+
return [group];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (skipProductStep) {
|
|
303
|
+
const onlyGroup =
|
|
304
|
+
data?.direct_groups?.[0] ?? data?.nominated_groups?.[0];
|
|
305
|
+
return onlyGroup ? [onlyGroup] : [];
|
|
306
|
+
}
|
|
307
|
+
return [];
|
|
308
|
+
}, [allGroups, selectedProductKey, skipProductStep, data]);
|
|
309
|
+
|
|
310
|
+
// Phase 2 of TECH-3712. Stable select handler for ProductSelectionSection.
|
|
311
|
+
// Single-group auto-select fires this on mount via the child's effect.
|
|
312
|
+
const handleProductSelect = useCallback(
|
|
313
|
+
(key: string | null, _group: FormProductGroup | null) => {
|
|
314
|
+
setSelectedProductKey(key);
|
|
315
|
+
},
|
|
316
|
+
[],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// Phase 2 of TECH-3712. Stable per-group setter passed to the wrapped
|
|
320
|
+
// PurposeSelectionSection so each card edits its own slice of state.
|
|
321
|
+
const setPurposesForGroup = useCallback(
|
|
322
|
+
(key: string, updated: Purpose[]) => {
|
|
323
|
+
setPurposesByGroupKey((prev) => {
|
|
324
|
+
const next = new Map(prev);
|
|
325
|
+
next.set(key, updated);
|
|
326
|
+
return next;
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
[],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// TECH-3712 (correction grouped path). Stable per-group setters for the
|
|
333
|
+
// CORRECTION maps. CorrectionDataSection's `setSelected` and
|
|
334
|
+
// `setCorrectionValues` props are value-setters (not state updaters), so
|
|
335
|
+
// these accept the full replacement value the child computed.
|
|
336
|
+
const setCorrectionElementsForGroup = useCallback(
|
|
337
|
+
(key: string, values: string[]) => {
|
|
338
|
+
setCorrectionElementsByGroupKey((prev) => {
|
|
339
|
+
const next = new Map(prev);
|
|
340
|
+
next.set(key, values);
|
|
341
|
+
return next;
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
[],
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const setCorrectionValuesForGroup = useCallback(
|
|
348
|
+
(
|
|
349
|
+
key: string,
|
|
350
|
+
values: Record<string, { current: string; updated: string }>,
|
|
351
|
+
) => {
|
|
352
|
+
setCorrectionValuesByGroupKey((prev) => {
|
|
353
|
+
const next = new Map(prev);
|
|
354
|
+
next.set(key, values);
|
|
355
|
+
return next;
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
[],
|
|
359
|
+
);
|
|
360
|
+
|
|
148
361
|
useEffect(() => {
|
|
149
362
|
if (data?.purposes) {
|
|
150
363
|
// Validate purposes array structure
|
|
@@ -164,11 +377,13 @@ export default function Form({
|
|
|
164
377
|
|
|
165
378
|
const uniqueElements = new Map();
|
|
166
379
|
extractedPurposes.forEach((purpose) => {
|
|
167
|
-
// Ensure data_elements is an array and filter out any null/undefined elements
|
|
168
|
-
//
|
|
380
|
+
// Ensure data_elements is an array and filter out any null/undefined elements.
|
|
381
|
+
// Include every data element the org holds for this user, regardless of current
|
|
382
|
+
// consent state. Revoking consent does not delete data (TECH-4089), so purposes
|
|
383
|
+
// with revoked/withdrawn consent must remain raisable for access/erasure/correction.
|
|
169
384
|
const validDataElements = Array.isArray(purpose.data_elements)
|
|
170
385
|
? purpose.data_elements.filter(
|
|
171
|
-
(element) => element !== null && element !== undefined && element.uuid
|
|
386
|
+
(element) => element !== null && element !== undefined && element.uuid
|
|
172
387
|
)
|
|
173
388
|
: [];
|
|
174
389
|
|
|
@@ -188,13 +403,14 @@ export default function Form({
|
|
|
188
403
|
Array.from(uniqueElements.values()) as DataElement[]
|
|
189
404
|
);
|
|
190
405
|
|
|
191
|
-
// Ensure data_elements are properly preserved with all properties
|
|
192
|
-
//
|
|
406
|
+
// Ensure data_elements are properly preserved with all properties.
|
|
407
|
+
// Include data elements regardless of consent state (revocation ≠ deletion,
|
|
408
|
+
// TECH-4089); only drop purposes that genuinely have no data elements.
|
|
193
409
|
const processedPurposes = extractedPurposes
|
|
194
410
|
.map((p) => {
|
|
195
411
|
const validDataElements = Array.isArray(p.data_elements)
|
|
196
412
|
? p.data_elements
|
|
197
|
-
.filter((de) => de !== null && de !== undefined && de.
|
|
413
|
+
.filter((de) => de !== null && de !== undefined && de.uuid)
|
|
198
414
|
.map((de) => ({
|
|
199
415
|
...de,
|
|
200
416
|
selected: false,
|
|
@@ -316,18 +532,28 @@ export default function Form({
|
|
|
316
532
|
setSelectedCorrectionElements([]);
|
|
317
533
|
setCorrectionValues({});
|
|
318
534
|
setNominationData({ nominee_email: '', nominee_mobile: '' });
|
|
535
|
+
// Phase 2 of TECH-3712. Drop product-step state too so switching from
|
|
536
|
+
// access to erasure (or back) starts the cards step from scratch.
|
|
537
|
+
setSelectedProductKey(null);
|
|
538
|
+
setPurposesByGroupKey(new Map());
|
|
539
|
+
// TECH-3712 (correction grouped path). Drop the per-group correction
|
|
540
|
+
// maps too so switching into/out of CORRECTION doesn't carry stale
|
|
541
|
+
// selections from a previous request-type pick.
|
|
542
|
+
setCorrectionElementsByGroupKey(new Map());
|
|
543
|
+
setCorrectionValuesByGroupKey(new Map());
|
|
319
544
|
if (data?.purposes && Array.isArray(data.purposes)) {
|
|
320
545
|
const extractedPurposes = data.purposes
|
|
321
546
|
.map((p) => p?.purpose)
|
|
322
547
|
.filter((p) => p !== null && p !== undefined);
|
|
323
548
|
|
|
324
|
-
// Ensure data_elements are properly preserved with all properties
|
|
325
|
-
//
|
|
549
|
+
// Ensure data_elements are properly preserved with all properties.
|
|
550
|
+
// Include data elements regardless of consent state (revocation ≠ deletion,
|
|
551
|
+
// TECH-4089); only drop purposes that genuinely have no data elements.
|
|
326
552
|
const processedPurposes = extractedPurposes
|
|
327
553
|
.map((p) => {
|
|
328
554
|
const validDataElements = Array.isArray(p.data_elements)
|
|
329
555
|
? p.data_elements
|
|
330
|
-
.filter((de) => de !== null && de !== undefined && de.
|
|
556
|
+
.filter((de) => de !== null && de !== undefined && de.uuid)
|
|
331
557
|
.map((de) => ({
|
|
332
558
|
...de,
|
|
333
559
|
selected: false,
|
|
@@ -361,6 +587,94 @@ export default function Form({
|
|
|
361
587
|
}
|
|
362
588
|
}, [requestType, data, grievanceOptions]);
|
|
363
589
|
|
|
590
|
+
// TECH-3712 UX: when `skipProductStep` is true (DP has exactly one consent
|
|
591
|
+
// group total), auto-select that group so step 3 (per-product purpose
|
|
592
|
+
// picker) renders directly without the user clicking a card. Placed AFTER
|
|
593
|
+
// the request-type-change clearing effect above so a request-type switch
|
|
594
|
+
// re-establishes the selection on the next tick — without this ordering,
|
|
595
|
+
// the clearing effect would wipe whatever this set on the same render.
|
|
596
|
+
// The child ProductSelectionSection has an equivalent mount-time effect,
|
|
597
|
+
// but that effect never fires here because the child is not rendered
|
|
598
|
+
// (see the JSX gate below).
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
if (!skipProductStep) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const onlyGroup =
|
|
604
|
+
data?.direct_groups?.[0] ?? data?.nominated_groups?.[0];
|
|
605
|
+
if (!onlyGroup) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const key = productGroupKey(onlyGroup);
|
|
609
|
+
setSelectedProductKey((prev) => (prev === key ? prev : key));
|
|
610
|
+
}, [skipProductStep, data, requestType]);
|
|
611
|
+
|
|
612
|
+
// Phase 2 of TECH-3712. Initialise purpose state for newly-selected groups
|
|
613
|
+
// and prune entries for groups that have been deselected. Idempotent — only
|
|
614
|
+
// mutates when the keyset actually changes. Declared AFTER the request-type
|
|
615
|
+
// clearing effect so a type switch doesn't wipe freshly seeded entries.
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
setPurposesByGroupKey((prev) => {
|
|
618
|
+
const next = new Map(prev);
|
|
619
|
+
const selectedKeys = new Set<string>();
|
|
620
|
+
for (const group of selectedGroups) {
|
|
621
|
+
const key = productGroupKey(group);
|
|
622
|
+
selectedKeys.add(key);
|
|
623
|
+
if (!next.has(key)) {
|
|
624
|
+
next.set(key, buildGroupPurposes(group));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const existingKey of Array.from(next.keys())) {
|
|
628
|
+
if (!selectedKeys.has(existingKey)) {
|
|
629
|
+
next.delete(existingKey);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return next;
|
|
633
|
+
});
|
|
634
|
+
}, [selectedGroups]);
|
|
635
|
+
|
|
636
|
+
// TECH-3712 (correction grouped path). Mirror the purpose-state effect
|
|
637
|
+
// for the CORRECTION per-group maps: seed empty slices for newly-selected
|
|
638
|
+
// groups and prune slices for deselected ones. Empty defaults keep the
|
|
639
|
+
// CorrectionDataSection signature unchanged (it expects a `string[]` and
|
|
640
|
+
// a `Record<string, {current, updated}>`).
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
setCorrectionElementsByGroupKey((prev) => {
|
|
643
|
+
const next = new Map(prev);
|
|
644
|
+
const selectedKeys = new Set<string>();
|
|
645
|
+
for (const group of selectedGroups) {
|
|
646
|
+
const key = productGroupKey(group);
|
|
647
|
+
selectedKeys.add(key);
|
|
648
|
+
if (!next.has(key)) {
|
|
649
|
+
next.set(key, []);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
for (const existingKey of Array.from(next.keys())) {
|
|
653
|
+
if (!selectedKeys.has(existingKey)) {
|
|
654
|
+
next.delete(existingKey);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return next;
|
|
658
|
+
});
|
|
659
|
+
setCorrectionValuesByGroupKey((prev) => {
|
|
660
|
+
const next = new Map(prev);
|
|
661
|
+
const selectedKeys = new Set<string>();
|
|
662
|
+
for (const group of selectedGroups) {
|
|
663
|
+
const key = productGroupKey(group);
|
|
664
|
+
selectedKeys.add(key);
|
|
665
|
+
if (!next.has(key)) {
|
|
666
|
+
next.set(key, {});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const existingKey of Array.from(next.keys())) {
|
|
670
|
+
if (!selectedKeys.has(existingKey)) {
|
|
671
|
+
next.delete(existingKey);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return next;
|
|
675
|
+
});
|
|
676
|
+
}, [selectedGroups]);
|
|
677
|
+
|
|
364
678
|
|
|
365
679
|
// Fetch API translations when language changes (purposes, data elements, grievance options)
|
|
366
680
|
useEffect(() => {
|
|
@@ -505,52 +819,60 @@ export default function Form({
|
|
|
505
819
|
return translatedName;
|
|
506
820
|
}, [data]);
|
|
507
821
|
|
|
508
|
-
const selectedErasurePurposeList = useMemo(
|
|
509
|
-
() =>
|
|
510
|
-
erasurePurposes
|
|
511
|
-
.filter((p) => p.selected || p.data_elements.some((de) => de.selected))
|
|
512
|
-
.map((p) => p.name)
|
|
513
|
-
.filter(Boolean),
|
|
514
|
-
[erasurePurposes]
|
|
515
|
-
);
|
|
516
|
-
|
|
517
|
-
const selectedErasurePurposeNames = useMemo(() => {
|
|
518
|
-
if (
|
|
519
|
-
selectedErasurePurposeList.length === 0 ||
|
|
520
|
-
selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL
|
|
521
|
-
) {
|
|
522
|
-
return t('revokeConsentModalYourPurposes');
|
|
523
|
-
}
|
|
524
|
-
return selectedErasurePurposeList.join(', ');
|
|
525
|
-
}, [selectedErasurePurposeList, t]);
|
|
526
|
-
|
|
527
822
|
const handleSubmitClick = () => {
|
|
528
|
-
if (!isFormValid){
|
|
529
|
-
return
|
|
530
|
-
};
|
|
531
|
-
if (requestType === RequestTypeEnum.ERASURE) {
|
|
532
|
-
setIsErasureConsentModalOpen(true);
|
|
823
|
+
if (!isFormValid) {
|
|
533
824
|
return;
|
|
534
825
|
}
|
|
535
|
-
setRevokeConsentOnFulfilment(false);
|
|
536
826
|
void handleSubmit(false);
|
|
537
827
|
};
|
|
538
828
|
|
|
539
|
-
const handleErasureModalChoice = (revoke: boolean) => {
|
|
540
|
-
setRevokeConsentOnFulfilment(revoke);
|
|
541
|
-
setIsErasureConsentModalOpen(false);
|
|
542
|
-
void handleSubmit(revoke);
|
|
543
|
-
};
|
|
544
|
-
|
|
545
|
-
const handleErasureModalClose = () => {
|
|
546
|
-
setIsErasureConsentModalOpen(false);
|
|
547
|
-
};
|
|
548
|
-
|
|
549
829
|
const handleSubmit = async (revokeConsent: boolean = false) => {
|
|
550
|
-
if (!isFormValid){
|
|
551
|
-
|
|
552
|
-
|
|
830
|
+
if (!isFormValid) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
553
833
|
|
|
834
|
+
// Phase 2 of TECH-3712. Multi-product DSAR batch path: fan one
|
|
835
|
+
// createCaseRequest per selected product via Promise.allSettled so
|
|
836
|
+
// partial failures don't tank the whole submission.
|
|
837
|
+
if (useGroupedRender && isGroupedRequestType(requestType) && selectedGroups.length > 0) {
|
|
838
|
+
try {
|
|
839
|
+
setIsLoading(true);
|
|
840
|
+
const result = await submitBatchCases(revokeConsent);
|
|
841
|
+
|
|
842
|
+
if (result.succeeded.length > 0) {
|
|
843
|
+
queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
|
|
844
|
+
// P2-SDK-4 of TECH-3712. Thread the full BatchCaseSubmitResult
|
|
845
|
+
// through to RequestSubmit so the success surface can list every
|
|
846
|
+
// case_id and render per-card retry buttons for any failures.
|
|
847
|
+
setBatchResult(result);
|
|
848
|
+
setIsSubmitted(true);
|
|
849
|
+
if (result.failed.length === 0) {
|
|
850
|
+
toast.success(t('requestSubmittedSuccess'));
|
|
851
|
+
} else {
|
|
852
|
+
toast.error(t('requestSubmissionFailed'));
|
|
853
|
+
}
|
|
854
|
+
if (isPopup) {
|
|
855
|
+
onCreateRequest?.();
|
|
856
|
+
}
|
|
857
|
+
} else {
|
|
858
|
+
toast.error(t('requestSubmissionFailed'));
|
|
859
|
+
}
|
|
860
|
+
} catch (error) {
|
|
861
|
+
console.error('Error submitting batch request:', error);
|
|
862
|
+
toast.error(t('submissionFailed'));
|
|
863
|
+
} finally {
|
|
864
|
+
setIsLoading(false);
|
|
865
|
+
if (isPopup) {
|
|
866
|
+
onClose?.();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Legacy / non-DSAR path: a single createCaseRequest with org-level
|
|
873
|
+
// request_details. Untouched by Phase 2 — nomination and grievance are
|
|
874
|
+
// always org-level, and DSAR falls here only when the backend hasn't
|
|
875
|
+
// shipped Phase 1 yet (useGroupedRender === false).
|
|
554
876
|
const requestDetails: {
|
|
555
877
|
purposes: UpdatePurpose[];
|
|
556
878
|
correction_data: UpdateCorrectionDataItem[];
|
|
@@ -639,29 +961,37 @@ export default function Form({
|
|
|
639
961
|
authContext,
|
|
640
962
|
consentClient
|
|
641
963
|
);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
// Case management returns the full CreateCaseResponse with { code, status, detail: { uuid, case_id } }
|
|
645
|
-
let caseId: string | null = null;
|
|
646
|
-
if (response) {
|
|
647
|
-
if ('detail' in response && response.detail) {
|
|
648
|
-
// Case management: wrapped response
|
|
649
|
-
caseId =
|
|
650
|
-
(response.detail as { case_id?: string; uuid?: string }).case_id ||
|
|
651
|
-
(response.detail as { case_id?: string; uuid?: string }).uuid ||
|
|
652
|
-
null;
|
|
653
|
-
} else if ('case_id' in response || 'uuid' in response) {
|
|
654
|
-
// Privacy center: unwrapped response
|
|
655
|
-
caseId =
|
|
656
|
-
(response as { case_id?: string; uuid?: string }).case_id ||
|
|
657
|
-
(response as { case_id?: string; uuid?: string }).uuid ||
|
|
658
|
-
null;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
964
|
+
const extracted = extractCaseId(response);
|
|
965
|
+
const caseId = extracted?.case_id || extracted?.uuid || null;
|
|
661
966
|
|
|
662
967
|
if (caseId) {
|
|
663
968
|
queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
|
|
664
|
-
|
|
969
|
+
// P2-SDK-4 of TECH-3712. Wrap the lone response in a synthetic
|
|
970
|
+
// BatchCaseSubmitResult so RequestSubmit can render this path with
|
|
971
|
+
// the same UI it uses for the grouped batch path. When the grouped
|
|
972
|
+
// flow auto-selects a single product card we hydrate the synthetic
|
|
973
|
+
// entry with that product's identity so the success surface can
|
|
974
|
+
// render a product label alongside the case ID; otherwise we fall
|
|
975
|
+
// back to empty strings (legacy nomination/grievance/non-grouped
|
|
976
|
+
// DSAR have no product context to render).
|
|
977
|
+
const productInfo =
|
|
978
|
+
useGroupedRender && selectedGroups.length === 1
|
|
979
|
+
? {
|
|
980
|
+
group_key: productGroupKey(selectedGroups[0]),
|
|
981
|
+
product_uuid: selectedGroups[0].product_uuid,
|
|
982
|
+
product_name: selectedGroups[0].product_name,
|
|
983
|
+
}
|
|
984
|
+
: { group_key: '', product_uuid: '', product_name: '' };
|
|
985
|
+
setBatchResult({
|
|
986
|
+
succeeded: [
|
|
987
|
+
{
|
|
988
|
+
...productInfo,
|
|
989
|
+
case_id: caseId,
|
|
990
|
+
uuid: extracted?.uuid ?? caseId,
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
failed: [],
|
|
994
|
+
});
|
|
665
995
|
setIsSubmitted(true);
|
|
666
996
|
toast.success(t('requestSubmittedSuccess'));
|
|
667
997
|
|
|
@@ -672,8 +1002,17 @@ export default function Form({
|
|
|
672
1002
|
toast.error(t('requestSubmissionFailed'));
|
|
673
1003
|
}
|
|
674
1004
|
} catch (error) {
|
|
1005
|
+
// `createCaseRequest` returns null on non-auth errors (per its
|
|
1006
|
+
// documented contract) — those land in the `else` branch above
|
|
1007
|
+
// with a generic toast. This catch only fires on unexpected
|
|
1008
|
+
// exceptions further up the legacy code path; surface whatever
|
|
1009
|
+
// message the error carries.
|
|
675
1010
|
console.error('Error submitting request:', error);
|
|
676
|
-
|
|
1011
|
+
const message =
|
|
1012
|
+
error instanceof Error && error.message
|
|
1013
|
+
? error.message
|
|
1014
|
+
: t('submissionFailed');
|
|
1015
|
+
toast.error(message);
|
|
677
1016
|
} finally {
|
|
678
1017
|
setIsLoading(false);
|
|
679
1018
|
if (isPopup) {
|
|
@@ -682,10 +1021,315 @@ export default function Form({
|
|
|
682
1021
|
}
|
|
683
1022
|
};
|
|
684
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Phase 2 of TECH-3712. Single-product POST shared by `submitBatchCases`
|
|
1026
|
+
* and `handleRetry` (P2-SDK-4). Builds the per-group payload (anchors,
|
|
1027
|
+
* purposes, principal) and invokes `createCaseRequest`. The caller is
|
|
1028
|
+
* responsible for catching/aggregating errors — this helper either
|
|
1029
|
+
* resolves with the response or throws.
|
|
1030
|
+
*
|
|
1031
|
+
* Anchors per group:
|
|
1032
|
+
* - `product_uuid` from the group.
|
|
1033
|
+
* - `data_principal_uuid` is set ONLY for nominated cards (the
|
|
1034
|
+
* requestor is acting on someone else's behalf — the nominator's
|
|
1035
|
+
* ConsentUser uuid). For direct cards we omit the field entirely
|
|
1036
|
+
* so the backend defaults it from `requestor_uuid` (derived from
|
|
1037
|
+
* the JWT). `formData.uuid` here is the FormDataOut session uuid,
|
|
1038
|
+
* NOT the requestor's ConsentUser uuid, and would be rejected by
|
|
1039
|
+
* the backend's ConsentUser lookup.
|
|
1040
|
+
*/
|
|
1041
|
+
const submitSingleProductCase = async (
|
|
1042
|
+
group: FormProductGroup,
|
|
1043
|
+
revokeConsent: boolean,
|
|
1044
|
+
): Promise<CreateCaseResponse | null> => {
|
|
1045
|
+
const groupKey = productGroupKey(group);
|
|
1046
|
+
|
|
1047
|
+
// ACCESS/ERASURE per-group purposes. Empty for CORRECTION — that path
|
|
1048
|
+
// populates `correction_data` below instead.
|
|
1049
|
+
const groupPurposes = purposesByGroupKey.get(groupKey) ?? [];
|
|
1050
|
+
const purposesPayload: UpdatePurpose[] =
|
|
1051
|
+
requestType === RequestTypeEnum.CORRECTION
|
|
1052
|
+
? []
|
|
1053
|
+
: groupPurposes
|
|
1054
|
+
.filter(
|
|
1055
|
+
(p) =>
|
|
1056
|
+
p.selected || p.data_elements.some((de) => de.selected),
|
|
1057
|
+
)
|
|
1058
|
+
.map((p) => ({
|
|
1059
|
+
purpose_uuid: p.uuid,
|
|
1060
|
+
data_element_uuids: p.data_elements
|
|
1061
|
+
.filter((de) => de.selected)
|
|
1062
|
+
.map((de) => de.uuid),
|
|
1063
|
+
}));
|
|
1064
|
+
|
|
1065
|
+
// TECH-3712 (correction grouped path). Per-group correction items: map
|
|
1066
|
+
// each selected element name to its {current, updated} pair. Names are
|
|
1067
|
+
// reverse-translated back to English via `getEnglishDataElementName`
|
|
1068
|
+
// so the backend always receives the canonical attribute name even
|
|
1069
|
+
// when the UI is showing a translated label.
|
|
1070
|
+
const groupSelectedNames =
|
|
1071
|
+
requestType === RequestTypeEnum.CORRECTION
|
|
1072
|
+
? correctionElementsByGroupKey.get(groupKey) ?? []
|
|
1073
|
+
: [];
|
|
1074
|
+
const groupValues =
|
|
1075
|
+
requestType === RequestTypeEnum.CORRECTION
|
|
1076
|
+
? correctionValuesByGroupKey.get(groupKey) ?? {}
|
|
1077
|
+
: {};
|
|
1078
|
+
const correctionPayload: UpdateCorrectionDataItem[] =
|
|
1079
|
+
requestType === RequestTypeEnum.CORRECTION
|
|
1080
|
+
? groupSelectedNames.map((name) => ({
|
|
1081
|
+
name: getEnglishDataElementName(name),
|
|
1082
|
+
curr_value: groupValues[name]?.current ?? '',
|
|
1083
|
+
new_value: groupValues[name]?.updated ?? '',
|
|
1084
|
+
}))
|
|
1085
|
+
: [];
|
|
1086
|
+
|
|
1087
|
+
const payload: UserDataRequest = {
|
|
1088
|
+
name: ' ',
|
|
1089
|
+
contact: formData.contact,
|
|
1090
|
+
request_type: requestType?.toLowerCase() as RequestTypeEnum,
|
|
1091
|
+
uuid: formData.uuid,
|
|
1092
|
+
supporting_docs_uuids: [...uploadedDocumentId],
|
|
1093
|
+
request_details: {
|
|
1094
|
+
purposes: purposesPayload,
|
|
1095
|
+
correction_data: correctionPayload,
|
|
1096
|
+
grievance_types: [],
|
|
1097
|
+
},
|
|
1098
|
+
time_period:
|
|
1099
|
+
requestType === RequestTypeEnum.ACCESS
|
|
1100
|
+
? parseInt(timePeriod || '30', 10)
|
|
1101
|
+
: 0,
|
|
1102
|
+
requestor_note: formData.additionalNote || '',
|
|
1103
|
+
...(requestType === RequestTypeEnum.ERASURE
|
|
1104
|
+
? { revoke_consent_on_fulfilment: revokeConsent }
|
|
1105
|
+
: {}),
|
|
1106
|
+
product_uuid: group.product_uuid,
|
|
1107
|
+
// Only set data_principal_uuid for nominee-driven cases. Direct
|
|
1108
|
+
// cards default server-side to the JWT-derived requestor_uuid.
|
|
1109
|
+
...(group.nominator
|
|
1110
|
+
? { data_principal_uuid: group.nominator.uuid }
|
|
1111
|
+
: {}),
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// Use the "unsafe" variant in the batch path so non-auth errors throw
|
|
1115
|
+
// instead of being swallowed to null — `Promise.allSettled` upstream
|
|
1116
|
+
// catches them and surfaces the backend message (e.g. `[400]
|
|
1117
|
+
// invalid_data_principal`) in `failed[].error` for that row.
|
|
1118
|
+
return await createCaseRequestUnsafe(
|
|
1119
|
+
organizationId || '',
|
|
1120
|
+
workspaceId || '',
|
|
1121
|
+
payload,
|
|
1122
|
+
isPopup,
|
|
1123
|
+
isCaseManagementContext,
|
|
1124
|
+
authContext,
|
|
1125
|
+
consentClient,
|
|
1126
|
+
);
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Phase 2 of TECH-3712. Fan one `createCaseRequestUnsafe` per selected
|
|
1131
|
+
* product group via `Promise.allSettled`, aggregate into
|
|
1132
|
+
* `BatchCaseSubmitResult`.
|
|
1133
|
+
*/
|
|
1134
|
+
const submitBatchCases = async (
|
|
1135
|
+
revokeConsent: boolean,
|
|
1136
|
+
): Promise<BatchCaseSubmitResult> => {
|
|
1137
|
+
const submissions = selectedGroups.map((group) =>
|
|
1138
|
+
submitSingleProductCase(group, revokeConsent).then(
|
|
1139
|
+
(response) => ({ group, response }),
|
|
1140
|
+
),
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
const results = await Promise.allSettled(submissions);
|
|
1144
|
+
const batchResult: BatchCaseSubmitResult = { succeeded: [], failed: [] };
|
|
1145
|
+
|
|
1146
|
+
results.forEach((result, idx) => {
|
|
1147
|
+
const group = selectedGroups[idx];
|
|
1148
|
+
const groupKey = productGroupKey(group);
|
|
1149
|
+
if (result.status === 'fulfilled') {
|
|
1150
|
+
const extracted = extractCaseId(result.value.response);
|
|
1151
|
+
if (extracted && (extracted.case_id || extracted.uuid)) {
|
|
1152
|
+
batchResult.succeeded.push({
|
|
1153
|
+
group_key: groupKey,
|
|
1154
|
+
product_uuid: group.product_uuid,
|
|
1155
|
+
product_name: group.product_name,
|
|
1156
|
+
case_id: extracted.case_id || extracted.uuid,
|
|
1157
|
+
uuid: extracted.uuid || extracted.case_id,
|
|
1158
|
+
});
|
|
1159
|
+
} else {
|
|
1160
|
+
batchResult.failed.push({
|
|
1161
|
+
group_key: groupKey,
|
|
1162
|
+
product_uuid: group.product_uuid,
|
|
1163
|
+
product_name: group.product_name,
|
|
1164
|
+
error: t('requestSubmissionFailed'),
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
} else {
|
|
1168
|
+
const reason = result.reason;
|
|
1169
|
+
const message =
|
|
1170
|
+
reason instanceof Error
|
|
1171
|
+
? reason.message
|
|
1172
|
+
: typeof reason === 'string'
|
|
1173
|
+
? reason
|
|
1174
|
+
: t('submissionFailed');
|
|
1175
|
+
batchResult.failed.push({
|
|
1176
|
+
group_key: groupKey,
|
|
1177
|
+
product_uuid: group.product_uuid,
|
|
1178
|
+
product_name: group.product_name,
|
|
1179
|
+
error: message,
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
return batchResult;
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* P2-SDK-4 of TECH-3712. Re-issue a single POST for one product-group
|
|
1189
|
+
* that previously failed in the batch. On success, promote the entry
|
|
1190
|
+
* from `batchResult.failed` to `batchResult.succeeded` so the UI can
|
|
1191
|
+
* update in place. On failure, refresh the recorded error message so
|
|
1192
|
+
* the user can try again.
|
|
1193
|
+
*
|
|
1194
|
+
* Keyed by `productGroupKey(group)` — NOT `product_uuid` — so that a
|
|
1195
|
+
* product picked both directly AND via a nominator (two distinct
|
|
1196
|
+
* groups with the same `product_uuid`) retries the right entry. No-op
|
|
1197
|
+
* for the legacy single-case path (which has no group context to
|
|
1198
|
+
* retry against — failures there don't land on this surface; they
|
|
1199
|
+
* short-circuit before `setIsSubmitted`).
|
|
1200
|
+
*/
|
|
1201
|
+
const handleRetry = useCallback(
|
|
1202
|
+
async (groupKey: string) => {
|
|
1203
|
+
if (groupKey === '') {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const group = selectedGroups.find(
|
|
1207
|
+
(g) => productGroupKey(g) === groupKey,
|
|
1208
|
+
);
|
|
1209
|
+
if (!group) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
setRetryingGroupKeys((prev) => {
|
|
1214
|
+
const next = new Set(prev);
|
|
1215
|
+
next.add(groupKey);
|
|
1216
|
+
return next;
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
try {
|
|
1220
|
+
const response = await submitSingleProductCase(group, false);
|
|
1221
|
+
const extracted = extractCaseId(response);
|
|
1222
|
+
if (extracted && (extracted.case_id || extracted.uuid)) {
|
|
1223
|
+
queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
|
|
1224
|
+
setBatchResult((prev) => {
|
|
1225
|
+
if (!prev) {
|
|
1226
|
+
return prev;
|
|
1227
|
+
}
|
|
1228
|
+
return {
|
|
1229
|
+
succeeded: [
|
|
1230
|
+
...prev.succeeded,
|
|
1231
|
+
{
|
|
1232
|
+
group_key: groupKey,
|
|
1233
|
+
product_uuid: group.product_uuid,
|
|
1234
|
+
product_name: group.product_name,
|
|
1235
|
+
case_id: extracted.case_id || extracted.uuid,
|
|
1236
|
+
uuid: extracted.uuid || extracted.case_id,
|
|
1237
|
+
},
|
|
1238
|
+
],
|
|
1239
|
+
failed: prev.failed.filter((f) => f.group_key !== groupKey),
|
|
1240
|
+
};
|
|
1241
|
+
});
|
|
1242
|
+
toast.success(t('requestSubmittedSuccess'));
|
|
1243
|
+
} else {
|
|
1244
|
+
setBatchResult((prev) => {
|
|
1245
|
+
if (!prev) {
|
|
1246
|
+
return prev;
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
succeeded: prev.succeeded,
|
|
1250
|
+
failed: prev.failed.map((f) =>
|
|
1251
|
+
f.group_key === groupKey
|
|
1252
|
+
? { ...f, error: t('requestSubmissionFailed') }
|
|
1253
|
+
: f,
|
|
1254
|
+
),
|
|
1255
|
+
};
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
const message =
|
|
1260
|
+
error instanceof Error ? error.message : t('submissionFailed');
|
|
1261
|
+
setBatchResult((prev) => {
|
|
1262
|
+
if (!prev) {
|
|
1263
|
+
return prev;
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
succeeded: prev.succeeded,
|
|
1267
|
+
failed: prev.failed.map((f) =>
|
|
1268
|
+
f.group_key === groupKey ? { ...f, error: message } : f,
|
|
1269
|
+
),
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1272
|
+
} finally {
|
|
1273
|
+
setRetryingGroupKeys((prev) => {
|
|
1274
|
+
const next = new Set(prev);
|
|
1275
|
+
next.delete(groupKey);
|
|
1276
|
+
return next;
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1281
|
+
[
|
|
1282
|
+
selectedGroups,
|
|
1283
|
+
queryClient,
|
|
1284
|
+
t,
|
|
1285
|
+
// submitSingleProductCase closes over many form-state values; we
|
|
1286
|
+
// intentionally don't list it to avoid identity churn re-rendering
|
|
1287
|
+
// RequestSubmit while a retry is in flight.
|
|
1288
|
+
],
|
|
1289
|
+
);
|
|
1290
|
+
|
|
685
1291
|
const isRequestDetailsValid = useCallback(() => {
|
|
686
|
-
if (!requestType){
|
|
687
|
-
|
|
688
|
-
|
|
1292
|
+
if (!requestType) {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Phase 2 of TECH-3712. Grouped DSAR validity: at least one product
|
|
1297
|
+
// selected, and EVERY selected group has at least one purpose/data
|
|
1298
|
+
// element chosen. The batch path fires one POST per selected group
|
|
1299
|
+
// (Promise.allSettled), so a partially-filled card would either
|
|
1300
|
+
// produce an empty `purposes` / `correction_data` payload that the
|
|
1301
|
+
// backend rejects, or land as a no-op case. Require completeness
|
|
1302
|
+
// across all selected groups before allowing submit.
|
|
1303
|
+
if (useGroupedRender && isGroupedRequestType(requestType)) {
|
|
1304
|
+
if (selectedGroups.length === 0) {
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
if (requestType === RequestTypeEnum.CORRECTION) {
|
|
1308
|
+
// For CORRECTION every selected group must have at least one
|
|
1309
|
+
// chosen data element AND every chosen element must carry both
|
|
1310
|
+
// current and updated values.
|
|
1311
|
+
return selectedGroups.every((group) => {
|
|
1312
|
+
const key = productGroupKey(group);
|
|
1313
|
+
const selected = correctionElementsByGroupKey.get(key) ?? [];
|
|
1314
|
+
const values = correctionValuesByGroupKey.get(key) ?? {};
|
|
1315
|
+
return (
|
|
1316
|
+
selected.length > 0 &&
|
|
1317
|
+
selected.every(
|
|
1318
|
+
(name) =>
|
|
1319
|
+
values[name]?.current?.trim() &&
|
|
1320
|
+
values[name]?.updated?.trim(),
|
|
1321
|
+
)
|
|
1322
|
+
);
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
return selectedGroups.every((group) => {
|
|
1326
|
+
const purposes = purposesByGroupKey.get(productGroupKey(group)) ?? [];
|
|
1327
|
+
return purposes.some(
|
|
1328
|
+
(p) => p.selected || p.data_elements.some((de) => de.selected),
|
|
1329
|
+
);
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
689
1333
|
switch (requestType) {
|
|
690
1334
|
case RequestTypeEnum.ACCESS:
|
|
691
1335
|
return accessPurposes.some(
|
|
@@ -718,6 +1362,11 @@ export default function Form({
|
|
|
718
1362
|
}
|
|
719
1363
|
}, [
|
|
720
1364
|
requestType,
|
|
1365
|
+
useGroupedRender,
|
|
1366
|
+
selectedGroups,
|
|
1367
|
+
purposesByGroupKey,
|
|
1368
|
+
correctionElementsByGroupKey,
|
|
1369
|
+
correctionValuesByGroupKey,
|
|
721
1370
|
accessPurposes,
|
|
722
1371
|
erasurePurposes,
|
|
723
1372
|
selectedGrievances,
|
|
@@ -918,13 +1567,13 @@ export default function Form({
|
|
|
918
1567
|
|
|
919
1568
|
return (
|
|
920
1569
|
<>
|
|
921
|
-
{isSubmitted && !isPopup ? (
|
|
1570
|
+
{isSubmitted && batchResult && !isPopup ? (
|
|
922
1571
|
<RequestSubmit
|
|
923
|
-
|
|
1572
|
+
result={batchResult}
|
|
924
1573
|
contact={formData?.contact}
|
|
925
1574
|
onCreateRequest={onCreateRequest}
|
|
926
|
-
|
|
927
|
-
|
|
1575
|
+
onRetry={handleRetry}
|
|
1576
|
+
retryingGroupKeys={retryingGroupKeys}
|
|
928
1577
|
/>
|
|
929
1578
|
) : (
|
|
930
1579
|
<div>
|
|
@@ -1128,7 +1777,115 @@ export default function Form({
|
|
|
1128
1777
|
<div style={{
|
|
1129
1778
|
padding: getVar('--pc-spacing-md'),
|
|
1130
1779
|
}}>
|
|
1131
|
-
{requestType
|
|
1780
|
+
{useGroupedRender && isGroupedRequestType(requestType) ? (
|
|
1781
|
+
<div
|
|
1782
|
+
style={{
|
|
1783
|
+
...pcStyles.flex,
|
|
1784
|
+
...pcStyles.flexCol,
|
|
1785
|
+
gap: getVar('--pc-spacing-md'),
|
|
1786
|
+
}}
|
|
1787
|
+
>
|
|
1788
|
+
{!skipProductStep && (
|
|
1789
|
+
<ProductSelectionSection
|
|
1790
|
+
directGroups={data?.direct_groups ?? []}
|
|
1791
|
+
nominatedGroups={data?.nominated_groups ?? []}
|
|
1792
|
+
selectedProductKey={selectedProductKey}
|
|
1793
|
+
onSelect={handleProductSelect}
|
|
1794
|
+
requestTypeLabel={requestTypeLabels[requestType]}
|
|
1795
|
+
isTranslating={isTranslating}
|
|
1796
|
+
/>
|
|
1797
|
+
)}
|
|
1798
|
+
{selectedGroups.length > 0 && (() => {
|
|
1799
|
+
const group = selectedGroups[0];
|
|
1800
|
+
const key = productGroupKey(group);
|
|
1801
|
+
const groupPurposes = purposesByGroupKey.get(key) ?? [];
|
|
1802
|
+
const isCorrection =
|
|
1803
|
+
requestType === RequestTypeEnum.CORRECTION;
|
|
1804
|
+
const groupDataElements = isCorrection
|
|
1805
|
+
? buildGroupDataElements(group)
|
|
1806
|
+
: [];
|
|
1807
|
+
const groupCorrectionSelected =
|
|
1808
|
+
correctionElementsByGroupKey.get(key) ?? [];
|
|
1809
|
+
const groupCorrectionValues =
|
|
1810
|
+
correctionValuesByGroupKey.get(key) ?? {};
|
|
1811
|
+
const nominatorLabel = group.nominator
|
|
1812
|
+
? group.nominator.name ??
|
|
1813
|
+
group.nominator.email ??
|
|
1814
|
+
''
|
|
1815
|
+
: '';
|
|
1816
|
+
|
|
1817
|
+
return (
|
|
1818
|
+
<div
|
|
1819
|
+
className="pc-product-purpose-panel"
|
|
1820
|
+
data-testid={`per-product-purposes-${key}`}
|
|
1821
|
+
>
|
|
1822
|
+
<div className="pc-product-purpose-panel-header">
|
|
1823
|
+
<div className="pc-product-purpose-panel-title-row">
|
|
1824
|
+
<PCText
|
|
1825
|
+
as="span"
|
|
1826
|
+
size="base"
|
|
1827
|
+
weight="semibold"
|
|
1828
|
+
color="primary"
|
|
1829
|
+
className="pc-product-purpose-panel-title"
|
|
1830
|
+
>
|
|
1831
|
+
{group.product_name}
|
|
1832
|
+
</PCText>
|
|
1833
|
+
{group.nominator && (
|
|
1834
|
+
<PCText
|
|
1835
|
+
as="span"
|
|
1836
|
+
size="xs"
|
|
1837
|
+
weight="medium"
|
|
1838
|
+
color="secondary"
|
|
1839
|
+
className="pc-product-purpose-panel-nominator"
|
|
1840
|
+
>
|
|
1841
|
+
{t('actingOnBehalfOfShort', {
|
|
1842
|
+
nominator: nominatorLabel,
|
|
1843
|
+
})}
|
|
1844
|
+
</PCText>
|
|
1845
|
+
)}
|
|
1846
|
+
</div>
|
|
1847
|
+
<PCText
|
|
1848
|
+
as="span"
|
|
1849
|
+
size="xs"
|
|
1850
|
+
color="secondary"
|
|
1851
|
+
className="pc-product-purpose-panel-subtitle"
|
|
1852
|
+
>
|
|
1853
|
+
{t('selectPurposesForThisProduct')}
|
|
1854
|
+
</PCText>
|
|
1855
|
+
</div>
|
|
1856
|
+
<div
|
|
1857
|
+
className="pc-product-purpose-panel-body"
|
|
1858
|
+
data-testid={`per-product-purposes-body-${key}`}
|
|
1859
|
+
>
|
|
1860
|
+
{isCorrection ? (
|
|
1861
|
+
<CorrectionDataSection
|
|
1862
|
+
dataElements={groupDataElements}
|
|
1863
|
+
selected={groupCorrectionSelected}
|
|
1864
|
+
setSelected={(values) =>
|
|
1865
|
+
setCorrectionElementsForGroup(key, values)
|
|
1866
|
+
}
|
|
1867
|
+
correctionValues={groupCorrectionValues}
|
|
1868
|
+
setCorrectionValues={(values) =>
|
|
1869
|
+
setCorrectionValuesForGroup(key, values)
|
|
1870
|
+
}
|
|
1871
|
+
isTranslating={isTranslating}
|
|
1872
|
+
/>
|
|
1873
|
+
) : (
|
|
1874
|
+
<PurposeSelectionSection
|
|
1875
|
+
purposes={groupPurposes}
|
|
1876
|
+
setPurposes={(updated) =>
|
|
1877
|
+
setPurposesForGroup(key, updated)
|
|
1878
|
+
}
|
|
1879
|
+
requestType={requestType}
|
|
1880
|
+
isTranslating={isTranslating}
|
|
1881
|
+
/>
|
|
1882
|
+
)}
|
|
1883
|
+
</div>
|
|
1884
|
+
</div>
|
|
1885
|
+
);
|
|
1886
|
+
})()}
|
|
1887
|
+
</div>
|
|
1888
|
+
) : requestType === RequestTypeEnum.ACCESS ||
|
|
1132
1889
|
requestType === RequestTypeEnum.ERASURE ? (
|
|
1133
1890
|
<PurposeSelectionSection
|
|
1134
1891
|
purposes={
|
|
@@ -1511,82 +2268,6 @@ export default function Form({
|
|
|
1511
2268
|
</div>
|
|
1512
2269
|
</div>
|
|
1513
2270
|
)}
|
|
1514
|
-
<PCModal
|
|
1515
|
-
isOpen={isErasureConsentModalOpen}
|
|
1516
|
-
onClose={handleErasureModalClose}
|
|
1517
|
-
size="md"
|
|
1518
|
-
showCloseButton={false}
|
|
1519
|
-
footer={
|
|
1520
|
-
<div className="pc-erasure-modal-footer">
|
|
1521
|
-
<PCButton
|
|
1522
|
-
variant="secondary"
|
|
1523
|
-
onClick={() => handleErasureModalChoice(false)}
|
|
1524
|
-
disabled={isLoading}
|
|
1525
|
-
>
|
|
1526
|
-
{t('revokeConsentNo')}
|
|
1527
|
-
</PCButton>
|
|
1528
|
-
<PCButton
|
|
1529
|
-
variant="primary"
|
|
1530
|
-
onClick={() => handleErasureModalChoice(true)}
|
|
1531
|
-
disabled={isLoading}
|
|
1532
|
-
isLoading={isLoading}
|
|
1533
|
-
>
|
|
1534
|
-
{t('revokeConsentYes')}
|
|
1535
|
-
</PCButton>
|
|
1536
|
-
</div>
|
|
1537
|
-
}
|
|
1538
|
-
>
|
|
1539
|
-
<div className="pc-erasure-modal-content">
|
|
1540
|
-
<div className="pc-erasure-modal-header">
|
|
1541
|
-
<div className="pc-erasure-modal-icon">
|
|
1542
|
-
<FiAlertCircle size={24} />
|
|
1543
|
-
</div>
|
|
1544
|
-
<p className="pc-erasure-modal-title">
|
|
1545
|
-
{t('revokeConsentModalTitle')}
|
|
1546
|
-
</p>
|
|
1547
|
-
<p className="pc-erasure-modal-question">
|
|
1548
|
-
{t('revokeConsentModalQuestion')}
|
|
1549
|
-
</p>
|
|
1550
|
-
</div>
|
|
1551
|
-
|
|
1552
|
-
<div className="pc-erasure-modal-purposes">
|
|
1553
|
-
<p className="pc-erasure-modal-purposes-label">
|
|
1554
|
-
{t('revokeConsentModalPurposesLabel')}
|
|
1555
|
-
</p>
|
|
1556
|
-
{selectedErasurePurposeList.length > 0 ? (
|
|
1557
|
-
<>
|
|
1558
|
-
{selectedErasurePurposeList
|
|
1559
|
-
.slice(0, MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL)
|
|
1560
|
-
.map((name) => (
|
|
1561
|
-
<div key={name} className="pc-erasure-modal-purpose-item">
|
|
1562
|
-
{name}
|
|
1563
|
-
</div>
|
|
1564
|
-
))}
|
|
1565
|
-
{selectedErasurePurposeList.length > MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL && (
|
|
1566
|
-
<span className="pc-erasure-modal-purposes-placeholder">
|
|
1567
|
-
{t('revokeConsentModalAndMore', {
|
|
1568
|
-
count: selectedErasurePurposeList.length - MAX_DISPLAYED_PURPOSE_NAMES_IN_ERASURE_MODAL,
|
|
1569
|
-
})}
|
|
1570
|
-
</span>
|
|
1571
|
-
)}
|
|
1572
|
-
</>
|
|
1573
|
-
) : (
|
|
1574
|
-
<span className="pc-erasure-modal-purposes-placeholder">
|
|
1575
|
-
{t('revokeConsentModalYourPurposes')}
|
|
1576
|
-
</span>
|
|
1577
|
-
)}
|
|
1578
|
-
</div>
|
|
1579
|
-
|
|
1580
|
-
<div className="pc-erasure-modal-warning">
|
|
1581
|
-
<p className="pc-erasure-modal-warning-text">
|
|
1582
|
-
<span className="pc-erasure-modal-warning-label">
|
|
1583
|
-
{t('revokeConsentModalIfYesLabel')}
|
|
1584
|
-
</span>{' '}
|
|
1585
|
-
{t('revokeConsentModalIfYesBody')}
|
|
1586
|
-
</p>
|
|
1587
|
-
</div>
|
|
1588
|
-
</div>
|
|
1589
|
-
</PCModal>
|
|
1590
2271
|
</>
|
|
1591
2272
|
);
|
|
1592
2273
|
}
|