@redacto.io/consent-sdk-react 9.2.0-beta.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 +15 -10
- package/CHANGELOG.md +6 -0
- package/dist/privacy-center.d.mts +56 -2
- package/dist/privacy-center.d.ts +56 -2
- package/dist/privacy-center.js +4213 -1256
- package/dist/privacy-center.mjs +4030 -1073
- package/package.json +1 -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/DataRequest/ProductSelectionSection.tsx +222 -0
- package/src/RedactoPrivacyCenter/components/Form.tsx +828 -33
- 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 +5 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +105 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
- package/tests/Form.test.tsx +1170 -0
- package/tests/ProductSelectionSection.test.tsx +293 -0
- package/tests/RequestSubmit.test.tsx +295 -0
|
@@ -8,9 +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';
|
|
11
|
+
import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
|
|
12
|
+
import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
|
|
12
13
|
import { pcStyles } from '../styles/pcStyles';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createCaseRequest,
|
|
16
|
+
createCaseRequestUnsafe,
|
|
17
|
+
getFormData,
|
|
18
|
+
} from '../lib/api';
|
|
14
19
|
import { ensureValidToken } from '../lib/utils';
|
|
15
20
|
import { useAuth } from '../context/AuthContext';
|
|
16
21
|
import { useBaseUrl } from '../context/BaseUrlContext';
|
|
@@ -22,8 +27,11 @@ import { useOrganization } from '../context/OrganizationContext';
|
|
|
22
27
|
import i18n from '../lib/i18n';
|
|
23
28
|
import { getLangParam } from '../lib/useCurrentLanguage';
|
|
24
29
|
import type {
|
|
30
|
+
BatchCaseSubmitResult,
|
|
31
|
+
CreateCaseResponse,
|
|
25
32
|
DataElement,
|
|
26
33
|
FormData,
|
|
34
|
+
FormProductGroup,
|
|
27
35
|
GrievanceOptions,
|
|
28
36
|
GrievanceTypeElement,
|
|
29
37
|
GrievanceValue,
|
|
@@ -37,6 +45,10 @@ import { RequestTypeEnum } from '../lib/types';
|
|
|
37
45
|
import CorrectionDataSection from './DataRequest/CorrectionDataSection';
|
|
38
46
|
import GrievanceDataSection from './DataRequest/GrievanceDataSection';
|
|
39
47
|
import NominationDataSection from './DataRequest/NominationDataSection';
|
|
48
|
+
import {
|
|
49
|
+
ProductSelectionSection,
|
|
50
|
+
productGroupKey,
|
|
51
|
+
} from './DataRequest/ProductSelectionSection';
|
|
40
52
|
import PurposeSelectionSection from './DataRequest/PurposeSelectionSection';
|
|
41
53
|
import RequestSubmit from './RequestSubmit';
|
|
42
54
|
import type { CaseHistoryProps } from './types';
|
|
@@ -44,6 +56,63 @@ import { FiChevronDown, FiChevronUp, FiX } from 'react-icons/fi';
|
|
|
44
56
|
|
|
45
57
|
const getVar = (varName: string): string => `var(${varName})`;
|
|
46
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
|
+
|
|
47
116
|
export default function Form({
|
|
48
117
|
onCreateRequest,
|
|
49
118
|
isPopup = false,
|
|
@@ -91,6 +160,18 @@ export default function Form({
|
|
|
91
160
|
});
|
|
92
161
|
const [erasurePurposes, setErasurePurposes] = useState<Purpose[]>([]);
|
|
93
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());
|
|
94
175
|
const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
|
|
95
176
|
string[]
|
|
96
177
|
>([]);
|
|
@@ -100,7 +181,29 @@ export default function Form({
|
|
|
100
181
|
const [correctionValues, setCorrectionValues] = useState<
|
|
101
182
|
Record<string, { current: string; updated: string }>
|
|
102
183
|
>({});
|
|
103
|
-
|
|
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
|
+
);
|
|
104
207
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
105
208
|
// Store the original English form data for submission — translations are display-only
|
|
106
209
|
const englishDataRef = useRef<FormData | null>(null);
|
|
@@ -139,6 +242,122 @@ export default function Form({
|
|
|
139
242
|
}
|
|
140
243
|
};
|
|
141
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
|
+
|
|
142
361
|
useEffect(() => {
|
|
143
362
|
if (data?.purposes) {
|
|
144
363
|
// Validate purposes array structure
|
|
@@ -313,6 +532,15 @@ export default function Form({
|
|
|
313
532
|
setSelectedCorrectionElements([]);
|
|
314
533
|
setCorrectionValues({});
|
|
315
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());
|
|
316
544
|
if (data?.purposes && Array.isArray(data.purposes)) {
|
|
317
545
|
const extractedPurposes = data.purposes
|
|
318
546
|
.map((p) => p?.purpose)
|
|
@@ -359,6 +587,94 @@ export default function Form({
|
|
|
359
587
|
}
|
|
360
588
|
}, [requestType, data, grievanceOptions]);
|
|
361
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
|
+
|
|
362
678
|
|
|
363
679
|
// Fetch API translations when language changes (purposes, data elements, grievance options)
|
|
364
680
|
useEffect(() => {
|
|
@@ -511,10 +827,52 @@ export default function Form({
|
|
|
511
827
|
};
|
|
512
828
|
|
|
513
829
|
const handleSubmit = async (revokeConsent: boolean = false) => {
|
|
514
|
-
if (!isFormValid){
|
|
515
|
-
|
|
516
|
-
|
|
830
|
+
if (!isFormValid) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
517
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).
|
|
518
876
|
const requestDetails: {
|
|
519
877
|
purposes: UpdatePurpose[];
|
|
520
878
|
correction_data: UpdateCorrectionDataItem[];
|
|
@@ -603,29 +961,37 @@ export default function Form({
|
|
|
603
961
|
authContext,
|
|
604
962
|
consentClient
|
|
605
963
|
);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
// Case management returns the full CreateCaseResponse with { code, status, detail: { uuid, case_id } }
|
|
609
|
-
let caseId: string | null = null;
|
|
610
|
-
if (response) {
|
|
611
|
-
if ('detail' in response && response.detail) {
|
|
612
|
-
// Case management: wrapped response
|
|
613
|
-
caseId =
|
|
614
|
-
(response.detail as { case_id?: string; uuid?: string }).case_id ||
|
|
615
|
-
(response.detail as { case_id?: string; uuid?: string }).uuid ||
|
|
616
|
-
null;
|
|
617
|
-
} else if ('case_id' in response || 'uuid' in response) {
|
|
618
|
-
// Privacy center: unwrapped response
|
|
619
|
-
caseId =
|
|
620
|
-
(response as { case_id?: string; uuid?: string }).case_id ||
|
|
621
|
-
(response as { case_id?: string; uuid?: string }).uuid ||
|
|
622
|
-
null;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
964
|
+
const extracted = extractCaseId(response);
|
|
965
|
+
const caseId = extracted?.case_id || extracted?.uuid || null;
|
|
625
966
|
|
|
626
967
|
if (caseId) {
|
|
627
968
|
queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
|
|
628
|
-
|
|
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
|
+
});
|
|
629
995
|
setIsSubmitted(true);
|
|
630
996
|
toast.success(t('requestSubmittedSuccess'));
|
|
631
997
|
|
|
@@ -636,8 +1002,17 @@ export default function Form({
|
|
|
636
1002
|
toast.error(t('requestSubmissionFailed'));
|
|
637
1003
|
}
|
|
638
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.
|
|
639
1010
|
console.error('Error submitting request:', error);
|
|
640
|
-
|
|
1011
|
+
const message =
|
|
1012
|
+
error instanceof Error && error.message
|
|
1013
|
+
? error.message
|
|
1014
|
+
: t('submissionFailed');
|
|
1015
|
+
toast.error(message);
|
|
641
1016
|
} finally {
|
|
642
1017
|
setIsLoading(false);
|
|
643
1018
|
if (isPopup) {
|
|
@@ -646,10 +1021,315 @@ export default function Form({
|
|
|
646
1021
|
}
|
|
647
1022
|
};
|
|
648
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
|
+
|
|
649
1291
|
const isRequestDetailsValid = useCallback(() => {
|
|
650
|
-
if (!requestType){
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
|
|
653
1333
|
switch (requestType) {
|
|
654
1334
|
case RequestTypeEnum.ACCESS:
|
|
655
1335
|
return accessPurposes.some(
|
|
@@ -682,6 +1362,11 @@ export default function Form({
|
|
|
682
1362
|
}
|
|
683
1363
|
}, [
|
|
684
1364
|
requestType,
|
|
1365
|
+
useGroupedRender,
|
|
1366
|
+
selectedGroups,
|
|
1367
|
+
purposesByGroupKey,
|
|
1368
|
+
correctionElementsByGroupKey,
|
|
1369
|
+
correctionValuesByGroupKey,
|
|
685
1370
|
accessPurposes,
|
|
686
1371
|
erasurePurposes,
|
|
687
1372
|
selectedGrievances,
|
|
@@ -882,11 +1567,13 @@ export default function Form({
|
|
|
882
1567
|
|
|
883
1568
|
return (
|
|
884
1569
|
<>
|
|
885
|
-
{isSubmitted && !isPopup ? (
|
|
1570
|
+
{isSubmitted && batchResult && !isPopup ? (
|
|
886
1571
|
<RequestSubmit
|
|
887
|
-
|
|
1572
|
+
result={batchResult}
|
|
888
1573
|
contact={formData?.contact}
|
|
889
1574
|
onCreateRequest={onCreateRequest}
|
|
1575
|
+
onRetry={handleRetry}
|
|
1576
|
+
retryingGroupKeys={retryingGroupKeys}
|
|
890
1577
|
/>
|
|
891
1578
|
) : (
|
|
892
1579
|
<div>
|
|
@@ -1090,7 +1777,115 @@ export default function Form({
|
|
|
1090
1777
|
<div style={{
|
|
1091
1778
|
padding: getVar('--pc-spacing-md'),
|
|
1092
1779
|
}}>
|
|
1093
|
-
{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 ||
|
|
1094
1889
|
requestType === RequestTypeEnum.ERASURE ? (
|
|
1095
1890
|
<PurposeSelectionSection
|
|
1096
1891
|
purposes={
|