@redacto.io/consent-sdk-react 9.2.0-beta.1 → 9.2.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/privacy-center.d.mts +6 -10
  3. package/dist/privacy-center.d.ts +6 -10
  4. package/dist/privacy-center.js +208 -850
  5. package/dist/privacy-center.mjs +208 -850
  6. package/package.json +1 -1
  7. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +4 -4
  8. package/src/RedactoPrivacyCenter/components/Form.tsx +214 -866
  9. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +44 -219
  10. package/src/RedactoPrivacyCenter/components/types.ts +14 -20
  11. package/src/RedactoPrivacyCenter/lib/api.ts +23 -58
  12. package/src/RedactoPrivacyCenter/lib/types.ts +0 -31
  13. package/src/RedactoPrivacyCenter/locales/as/translation.json +2 -2
  14. package/src/RedactoPrivacyCenter/locales/bho/translation.json +2 -2
  15. package/src/RedactoPrivacyCenter/locales/bn/translation.json +2 -2
  16. package/src/RedactoPrivacyCenter/locales/brx/translation.json +2 -2
  17. package/src/RedactoPrivacyCenter/locales/doi/translation.json +2 -2
  18. package/src/RedactoPrivacyCenter/locales/en/translation.json +2 -2
  19. package/src/RedactoPrivacyCenter/locales/gom/translation.json +2 -2
  20. package/src/RedactoPrivacyCenter/locales/gu/translation.json +2 -2
  21. package/src/RedactoPrivacyCenter/locales/hi/translation.json +2 -2
  22. package/src/RedactoPrivacyCenter/locales/kn/translation.json +2 -2
  23. package/src/RedactoPrivacyCenter/locales/ks/translation.json +2 -2
  24. package/src/RedactoPrivacyCenter/locales/mai/translation.json +2 -2
  25. package/src/RedactoPrivacyCenter/locales/ml/translation.json +2 -2
  26. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +2 -2
  27. package/src/RedactoPrivacyCenter/locales/mr/translation.json +2 -2
  28. package/src/RedactoPrivacyCenter/locales/ne/translation.json +2 -2
  29. package/src/RedactoPrivacyCenter/locales/or/translation.json +2 -2
  30. package/src/RedactoPrivacyCenter/locales/pa/translation.json +2 -2
  31. package/src/RedactoPrivacyCenter/locales/sa/translation.json +2 -2
  32. package/src/RedactoPrivacyCenter/locales/sat/translation.json +2 -2
  33. package/src/RedactoPrivacyCenter/locales/sd/translation.json +2 -2
  34. package/src/RedactoPrivacyCenter/locales/ta/translation.json +2 -2
  35. package/src/RedactoPrivacyCenter/locales/te/translation.json +2 -2
  36. package/src/RedactoPrivacyCenter/locales/ur/translation.json +2 -2
  37. package/tests/Form.test.tsx +123 -149
  38. package/tests/ProductSelectionSection.test.tsx +39 -4
  39. package/tests/RequestSubmit.test.tsx +27 -187
@@ -11,11 +11,7 @@ import { toast } from '../utils/toast';
11
11
  import { PCButton, PCCheckbox, PCInput, PCLoader, PCSelect, PCText, PCTextarea } from '../ui';
12
12
  import { buildGroupDataElements, buildGroupPurposes } from './groupHelpers';
13
13
  import { pcStyles } from '../styles/pcStyles';
14
- import {
15
- createCaseRequest,
16
- createCaseRequestUnsafe,
17
- getFormData,
18
- } from '../lib/api';
14
+ import { createCaseRequest, getFormData } from '../lib/api';
19
15
  import { ensureValidToken } from '../lib/utils';
20
16
  import { useAuth } from '../context/AuthContext';
21
17
  import { useBaseUrl } from '../context/BaseUrlContext';
@@ -27,9 +23,7 @@ import { useOrganization } from '../context/OrganizationContext';
27
23
  import i18n from '../lib/i18n';
28
24
  import { getLangParam } from '../lib/useCurrentLanguage';
29
25
  import type {
30
- BatchCaseSubmitResult,
31
26
  CreateCaseResponse,
32
- DataElement,
33
27
  FormData,
34
28
  FormProductGroup,
35
29
  GrievanceOptions,
@@ -72,22 +66,33 @@ type ExtractedCaseRef = { uuid: string; case_id: string };
72
66
  type CaseIdContainer = { case_id?: string; uuid?: string };
73
67
 
74
68
  /**
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).
69
+ * Request types that engage the per-product picker and require a
70
+ * product anchor (`product_uuid`) on the submitted case. Nomination
71
+ * and grievance are org-level and skip the product step entirely.
81
72
  */
82
- const GROUPED_REQUEST_TYPES: ReadonlySet<RequestTypeEnum> = new Set([
73
+ const PRODUCT_SCOPED_REQUEST_TYPES: ReadonlySet<RequestTypeEnum> = new Set([
83
74
  RequestTypeEnum.ACCESS,
84
75
  RequestTypeEnum.ERASURE,
85
76
  RequestTypeEnum.CORRECTION,
86
77
  ]);
87
78
 
88
- const isGroupedRequestType = (
79
+ const isProductScopedRequestType = (
89
80
  rt: RequestTypeEnum | null,
90
- ): rt is RequestTypeEnum => rt !== null && GROUPED_REQUEST_TYPES.has(rt);
81
+ ): rt is RequestTypeEnum =>
82
+ rt !== null && PRODUCT_SCOPED_REQUEST_TYPES.has(rt);
83
+
84
+ /**
85
+ * Result shape rendered by RequestSubmit after a successful submission.
86
+ * Product fields are populated only for product-scoped request types
87
+ * (ACCESS / ERASURE / CORRECTION); nomination and grievance leave them
88
+ * as empty strings so the success card omits the product label.
89
+ */
90
+ type SubmittedCase = {
91
+ case_id: string;
92
+ uuid: string;
93
+ product_name: string;
94
+ product_uuid: string;
95
+ };
91
96
 
92
97
  /**
93
98
  * Pull the `case_id` out of a `createCaseRequest` response, handling both
@@ -158,60 +163,39 @@ export default function Form({
158
163
  additionalNote: '',
159
164
  confirmCheckBox: false,
160
165
  });
161
- const [erasurePurposes, setErasurePurposes] = useState<Purpose[]>([]);
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
+ // Selected product-group key on the product-cards step. Keyed by
167
+ // `productGroupKey(group)` so the same product reached both directly
168
+ // AND via a nominator stays addressable as two distinct selections.
169
+ // null when no product is selected (or for org-level request types).
166
170
  const [selectedProductKey, setSelectedProductKey] = useState<string | null>(
167
171
  null,
168
172
  );
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());
173
+ // Purpose state for the currently-selected product group. Seeded from
174
+ // `buildGroupPurposes(group)` whenever `selectedProductKey` changes, then
175
+ // mutated in place by `PurposeSelectionSection` as the user picks
176
+ // purposes / data elements. Single-select means we never need a Map.
177
+ const [selectedPurposes, setSelectedPurposes] = useState<Purpose[]>([]);
178
+ // CORRECTION selections for the currently-selected product group. These
179
+ // are flat (not keyed by product) because the picker is single-select —
180
+ // switching products re-seeds them via the same effect that re-seeds
181
+ // `selectedPurposes`. Names are the data-element names visible in the
182
+ // UI; they're reverse-translated to English at submit time.
175
183
  const [selectedCorrectionElements, setSelectedCorrectionElements] = useState<
176
184
  string[]
177
185
  >([]);
178
- const [uniqueDataElements, setUniqueDataElements] = useState<DataElement[]>(
179
- []
180
- );
181
186
  const [correctionValues, setCorrectionValues] = useState<
182
187
  Record<string, { current: string; updated: string }>
183
188
  >({});
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>(
189
+ // Result rendered by `RequestSubmit` after a successful submission.
190
+ // Replaces the prior batch-shape now that submit is 1-form / 1-case.
191
+ // Product fields are empty strings for nomination / grievance (no
192
+ // product anchor) so the success card omits the product label.
193
+ const [submittedCase, setSubmittedCase] = useState<SubmittedCase | null>(
198
194
  null,
199
195
  );
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
- );
207
196
  const fileInputRef = useRef<HTMLInputElement>(null);
208
197
  // Store the original English form data for submission — translations are display-only
209
198
  const englishDataRef = useRef<FormData | null>(null);
210
- // Store selections to restore after translation
211
- const selectionsToRestoreRef = useRef<{
212
- access: Map<string, { purposeSelected: boolean; dataElementSelections: Map<string, boolean> }>;
213
- erasure: Map<string, { purposeSelected: boolean; dataElementSelections: Map<string, boolean> }>;
214
- } | null>(null);
215
199
  const [uploadedDocumentId, setUploadedDocumentId] = useState<string[]>([]);
216
200
  const [uploadedDocuments, setUploadedDocuments] = useState<
217
201
  { uuid: string; name: string }[]
@@ -242,73 +226,41 @@ export default function Form({
242
226
  }
243
227
  };
244
228
 
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.
229
+ // Union of direct + nominated product groups exposed by the backend.
230
+ // Drives both the product-cards step and the per-product picker beneath.
269
231
  const allGroups = useMemo<FormProductGroup[]>(() => {
270
- if (!useGroupedRender || !data) {
232
+ if (!data) {
271
233
  return [];
272
234
  }
273
235
  return [...(data.direct_groups ?? []), ...(data.nominated_groups ?? [])];
274
- }, [data, useGroupedRender]);
236
+ }, [data]);
275
237
 
276
238
  // TECH-3712 UX: when the DP has exactly one consent group total
277
239
  // (direct + nominated combined), the product-selection step is friction —
278
240
  // 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[]>(() => {
241
+ // entirely and auto-select the lone group below so the per-product
242
+ // picker renders without an intermediate card. With zero groups we
243
+ // still render the section so the empty-state message is visible.
244
+ const skipProductStep = allGroups.length === 1;
245
+
246
+ // The single product-group the user is currently working with. Null
247
+ // when no product is selected (the picker is shown empty / unselected)
248
+ // or when the request type is org-level (nomination / grievance).
249
+ const selectedGroup = useMemo<FormProductGroup | null>(() => {
294
250
  if (selectedProductKey) {
295
- const group = allGroups.find(
296
- (g) => productGroupKey(g) === selectedProductKey,
251
+ return (
252
+ allGroups.find((g) => productGroupKey(g) === selectedProductKey) ??
253
+ null
297
254
  );
298
- if (group) {
299
- return [group];
300
- }
301
255
  }
302
256
  if (skipProductStep) {
303
- const onlyGroup =
304
- data?.direct_groups?.[0] ?? data?.nominated_groups?.[0];
305
- return onlyGroup ? [onlyGroup] : [];
257
+ return allGroups[0] ?? null;
306
258
  }
307
- return [];
308
- }, [allGroups, selectedProductKey, skipProductStep, data]);
259
+ return null;
260
+ }, [allGroups, selectedProductKey, skipProductStep]);
309
261
 
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.
262
+ // Stable select handler for ProductSelectionSection. Single-group
263
+ // auto-select fires this on mount via the child's effect.
312
264
  const handleProductSelect = useCallback(
313
265
  (key: string | null, _group: FormProductGroup | null) => {
314
266
  setSelectedProductKey(key);
@@ -316,120 +268,6 @@ export default function Form({
316
268
  [],
317
269
  );
318
270
 
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
-
361
- useEffect(() => {
362
- if (data?.purposes) {
363
- // Validate purposes array structure
364
- if (!Array.isArray(data.purposes)) {
365
- console.warn('Form data purposes is not an array:', data.purposes);
366
- return;
367
- }
368
-
369
- const extractedPurposes = data.purposes
370
- .map((p) => p?.purpose)
371
- .filter((p) => p !== null && p !== undefined);
372
-
373
- if (extractedPurposes.length === 0) {
374
- console.warn('No valid purposes found in form data');
375
- return;
376
- }
377
-
378
- const uniqueElements = new Map();
379
- extractedPurposes.forEach((purpose) => {
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.
384
- const validDataElements = Array.isArray(purpose.data_elements)
385
- ? purpose.data_elements.filter(
386
- (element) => element !== null && element !== undefined && element.uuid
387
- )
388
- : [];
389
-
390
- // Data elements are validated and processed
391
-
392
- validDataElements.forEach((element) => {
393
- if (element.uuid && !uniqueElements.has(element.uuid)) {
394
- uniqueElements.set(element.uuid, {
395
- ...element,
396
- purposeName: purpose.name,
397
- });
398
- }
399
- });
400
- });
401
-
402
- setUniqueDataElements(
403
- Array.from(uniqueElements.values()) as DataElement[]
404
- );
405
-
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.
409
- const processedPurposes = extractedPurposes
410
- .map((p) => {
411
- const validDataElements = Array.isArray(p.data_elements)
412
- ? p.data_elements
413
- .filter((de) => de !== null && de !== undefined && de.uuid)
414
- .map((de) => ({
415
- ...de,
416
- selected: false,
417
- }))
418
- : [];
419
-
420
- return {
421
- ...p,
422
- selected: false,
423
- data_elements: validDataElements,
424
- };
425
- })
426
- .filter((p) => p.data_elements.length > 0);
427
-
428
- setAccessPurposes(processedPurposes);
429
- setErasurePurposes(processedPurposes);
430
- }
431
- }, [data]);
432
-
433
271
  const fetchAndApplyTranslations = useCallback(async (langValue: string) => {
434
272
  if (!organizationId || !workspaceId || !data) {
435
273
  return;
@@ -462,37 +300,6 @@ export default function Form({
462
300
  return;
463
301
  }
464
302
 
465
- const currentAccessSelections = new Map<string, {
466
- purposeSelected: boolean;
467
- dataElementSelections: Map<string, boolean>;
468
- }>();
469
- const currentErasureSelections = new Map<string, {
470
- purposeSelected: boolean;
471
- dataElementSelections: Map<string, boolean>;
472
- }>();
473
-
474
- accessPurposes.forEach((purpose) => {
475
- const dataElementSelections = new Map<string, boolean>();
476
- purpose.data_elements.forEach((de) => {
477
- dataElementSelections.set(de.uuid, de.selected);
478
- });
479
- currentAccessSelections.set(purpose.uuid, {
480
- purposeSelected: purpose.selected,
481
- dataElementSelections,
482
- });
483
- });
484
-
485
- erasurePurposes.forEach((purpose) => {
486
- const dataElementSelections = new Map<string, boolean>();
487
- purpose.data_elements.forEach((de) => {
488
- dataElementSelections.set(de.uuid, de.selected);
489
- });
490
- currentErasureSelections.set(purpose.uuid, {
491
- purposeSelected: purpose.selected,
492
- dataElementSelections,
493
- });
494
- });
495
-
496
303
  if (!translatedFormData.purposes ||
497
304
  !Array.isArray(translatedFormData.purposes) ||
498
305
  translatedFormData.purposes.length === 0) {
@@ -513,11 +320,6 @@ export default function Form({
513
320
  });
514
321
  setSelectedGrievances(updatedGrievanceSelections);
515
322
  }
516
-
517
- selectionsToRestoreRef.current = {
518
- access: currentAccessSelections,
519
- erasure: currentErasureSelections,
520
- };
521
323
  } catch (error) {
522
324
  console.error('Error fetching translation:', error);
523
325
  // Fallback to English when translation API fails
@@ -525,155 +327,71 @@ export default function Form({
525
327
  } finally {
526
328
  setIsTranslating(false);
527
329
  }
528
- }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, accessPurposes, erasurePurposes, selectedGrievances, t]);
529
-
530
- // Clear previous selections when request type changes
330
+ }, [organizationId, workspaceId, data, formData.contact, isPopup, isCaseManagementContext, authContext, consentClient, selectedGrievances, t]);
331
+
332
+ // Clear product / org-level selections when request type changes.
333
+ // Per-product state (selectedPurposes, selectedCorrectionElements,
334
+ // correctionValues) is owned by the seed effect below — clearing it
335
+ // here too would race with that effect's re-seed when skipProductStep
336
+ // is true (the sole group stays selected across request-type changes
337
+ // because the auto-select effect refires on requestType).
531
338
  useEffect(() => {
532
- setSelectedCorrectionElements([]);
533
- setCorrectionValues({});
339
+ if (!skipProductStep) {
340
+ setSelectedProductKey(null);
341
+ }
534
342
  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());
544
- if (data?.purposes && Array.isArray(data.purposes)) {
545
- const extractedPurposes = data.purposes
546
- .map((p) => p?.purpose)
547
- .filter((p) => p !== null && p !== undefined);
548
-
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.
552
- const processedPurposes = extractedPurposes
553
- .map((p) => {
554
- const validDataElements = Array.isArray(p.data_elements)
555
- ? p.data_elements
556
- .filter((de) => de !== null && de !== undefined && de.uuid)
557
- .map((de) => ({
558
- ...de,
559
- selected: false,
560
- }))
561
- : [];
562
-
563
- return {
564
- ...p,
565
- selected: false,
566
- data_elements: validDataElements,
567
- };
568
- })
569
- .filter((p) => p.data_elements.length > 0);
570
-
571
- setAccessPurposes(processedPurposes);
572
- setErasurePurposes(processedPurposes);
573
-
574
- if (
575
- requestType === RequestTypeEnum.GRIEVANCE &&
576
- grievanceOptions.length > 0
577
- ) {
578
- const resetGrievances = grievanceOptions.reduce(
579
- (acc, option) => {
580
- acc[option.value] = false;
581
- return acc;
582
- },
583
- {} as Record<string, boolean>
584
- );
585
- setSelectedGrievances(resetGrievances);
586
- }
343
+
344
+ if (
345
+ requestType === RequestTypeEnum.GRIEVANCE &&
346
+ grievanceOptions.length > 0
347
+ ) {
348
+ const resetGrievances = grievanceOptions.reduce(
349
+ (acc, option) => {
350
+ acc[option.value] = false;
351
+ return acc;
352
+ },
353
+ {} as Record<string, boolean>,
354
+ );
355
+ setSelectedGrievances(resetGrievances);
587
356
  }
588
- }, [requestType, data, grievanceOptions]);
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).
357
+ }, [requestType, grievanceOptions, skipProductStep]);
358
+
359
+ // TECH-3712 UX: when `skipProductStep` is true (DP has exactly one
360
+ // consent group total), auto-select that group so the per-product
361
+ // picker renders directly without the user clicking a card. Placed
362
+ // AFTER the request-type-change clearing effect above so a request-type
363
+ // switch re-establishes the selection on the next tick. The child
364
+ // ProductSelectionSection has an equivalent mount-time effect, but
365
+ // that effect never fires here because the child is not rendered.
599
366
  useEffect(() => {
600
367
  if (!skipProductStep) {
601
368
  return;
602
369
  }
603
- const onlyGroup =
604
- data?.direct_groups?.[0] ?? data?.nominated_groups?.[0];
370
+ const onlyGroup = allGroups[0];
605
371
  if (!onlyGroup) {
606
372
  return;
607
373
  }
608
374
  const key = productGroupKey(onlyGroup);
609
375
  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}>`).
376
+ }, [skipProductStep, allGroups, requestType]);
377
+
378
+ // Seed (and re-seed on product switch or request-type switch) the
379
+ // purpose / correction state from the currently-selected group.
380
+ // Single-select means one re-seed per product change — no Map
381
+ // reconciliation needed. Clears selections entirely when the user
382
+ // de-selects the chip (selectedGroup -> null) or when the request
383
+ // type switches into NOMINATION / GRIEVANCE.
641
384
  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]);
385
+ if (!selectedGroup || !isProductScopedRequestType(requestType)) {
386
+ setSelectedPurposes([]);
387
+ setSelectedCorrectionElements([]);
388
+ setCorrectionValues({});
389
+ return;
390
+ }
391
+ setSelectedPurposes(buildGroupPurposes(selectedGroup));
392
+ setSelectedCorrectionElements([]);
393
+ setCorrectionValues({});
394
+ }, [selectedGroup, requestType]);
677
395
 
678
396
 
679
397
  // Fetch API translations when language changes (purposes, data elements, grievance options)
@@ -823,56 +541,41 @@ export default function Form({
823
541
  if (!isFormValid) {
824
542
  return;
825
543
  }
826
- void handleSubmit(false);
544
+ // For ERASURE, set revoke_consent_on_fulfilment=true so the
545
+ // backend's auto-revoke task fires when the case is fulfilled.
546
+ // Erasing data without revoking the consent that authorized its
547
+ // processing is incoherent per DPDPA Sec 12. Other request types
548
+ // (ACCESS / CORRECTION / NOMINATION / GRIEVANCE) do not engage
549
+ // the auto-revoke path on the backend and pass false here.
550
+ void handleSubmit(requestType === RequestTypeEnum.ERASURE);
827
551
  };
828
552
 
553
+ /**
554
+ * Submit the form as a single case. Builds the payload from the
555
+ * currently-selected product group (for ACCESS/ERASURE/CORRECTION) or
556
+ * from the org-level state (for NOMINATION/GRIEVANCE), then POSTs once
557
+ * via `createCaseRequest`. The 1-form -> 1-case rule means no
558
+ * fan-out and no partial-failure UI to maintain.
559
+ *
560
+ * Anchors:
561
+ * - `product_uuid` is set ONLY for product-scoped request types and
562
+ * ONLY from `selectedGroup.product_uuid`. NOMINATION/GRIEVANCE
563
+ * never carry a product anchor.
564
+ * - `data_principal_uuid` is set ONLY for nominated cards (acting
565
+ * on someone else's behalf — the nominator's ConsentUser uuid).
566
+ * Direct cards omit the field so the backend defaults it from
567
+ * the JWT-derived requestor_uuid. `formData.uuid` is the
568
+ * FormDataOut session uuid, NOT the requestor's ConsentUser uuid,
569
+ * and would be rejected by the backend's ConsentUser lookup.
570
+ */
829
571
  const handleSubmit = async (revokeConsent: boolean = false) => {
830
572
  if (!isFormValid) {
831
573
  return;
832
574
  }
833
575
 
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
- }
576
+ const isProductScoped = isProductScopedRequestType(requestType);
577
+ const anchorGroup = isProductScoped ? selectedGroup : null;
871
578
 
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).
876
579
  const requestDetails: {
877
580
  purposes: UpdatePurpose[];
878
581
  correction_data: UpdateCorrectionDataItem[];
@@ -884,19 +587,11 @@ export default function Form({
884
587
  grievance_types: [],
885
588
  };
886
589
 
887
- if (requestType === RequestTypeEnum.ACCESS) {
888
- requestDetails.purposes = accessPurposes
889
- .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
890
- .map((p) => ({
891
- purpose_uuid: p.uuid,
892
- data_element_uuids: p.data_elements
893
- .filter((de) => de.selected)
894
- .map((de) => de.uuid),
895
- }));
896
- }
897
-
898
- if (requestType === RequestTypeEnum.ERASURE) {
899
- requestDetails.purposes = erasurePurposes
590
+ if (
591
+ requestType === RequestTypeEnum.ACCESS ||
592
+ requestType === RequestTypeEnum.ERASURE
593
+ ) {
594
+ requestDetails.purposes = selectedPurposes
900
595
  .filter((p) => p.selected || p.data_elements.some((de) => de.selected))
901
596
  .map((p) => ({
902
597
  purpose_uuid: p.uuid,
@@ -910,9 +605,9 @@ export default function Form({
910
605
  requestDetails.correction_data = selectedCorrectionElements.map(
911
606
  (name) => ({
912
607
  name: getEnglishDataElementName(name),
913
- curr_value: correctionValues[name]?.current || '',
914
- new_value: correctionValues[name]?.updated || '',
915
- })
608
+ curr_value: correctionValues[name]?.current ?? '',
609
+ new_value: correctionValues[name]?.updated ?? '',
610
+ }),
916
611
  );
917
612
  }
918
613
 
@@ -948,6 +643,10 @@ export default function Form({
948
643
  ...(requestType === RequestTypeEnum.ERASURE
949
644
  ? { revoke_consent_on_fulfilment: revokeConsent }
950
645
  : {}),
646
+ ...(anchorGroup ? { product_uuid: anchorGroup.product_uuid } : {}),
647
+ ...(anchorGroup?.nominator
648
+ ? { data_principal_uuid: anchorGroup.nominator.uuid }
649
+ : {}),
951
650
  };
952
651
 
953
652
  try {
@@ -959,44 +658,28 @@ export default function Form({
959
658
  isPopup,
960
659
  isCaseManagementContext, // Use session auth if in case management context
961
660
  authContext,
962
- consentClient
661
+ consentClient,
963
662
  );
964
663
  const extracted = extractCaseId(response);
965
664
  const caseId = extracted?.case_id || extracted?.uuid || null;
966
665
 
967
666
  if (caseId) {
968
667
  queryClient.invalidateQueries({ queryKey: ['caseHistory'] });
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: [],
668
+ setSubmittedCase({
669
+ case_id: caseId,
670
+ uuid: extracted?.uuid ?? caseId,
671
+ product_name: anchorGroup?.product_name ?? '',
672
+ product_uuid: anchorGroup?.product_uuid ?? '',
994
673
  });
995
674
  setIsSubmitted(true);
996
675
  toast.success(t('requestSubmittedSuccess'));
997
676
 
998
677
  if (isPopup) {
999
678
  onCreateRequest?.();
679
+ // Only dismiss the popup on success. Closing in `finally`
680
+ // would discard the operator's filled form on a transient
681
+ // failure with no way to retry.
682
+ onClose?.();
1000
683
  }
1001
684
  } else {
1002
685
  toast.error(t('requestSubmissionFailed'));
@@ -1005,8 +688,7 @@ export default function Form({
1005
688
  // `createCaseRequest` returns null on non-auth errors (per its
1006
689
  // documented contract) — those land in the `else` branch above
1007
690
  // 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.
691
+ // exceptions; surface whatever message the error carries.
1010
692
  console.error('Error submitting request:', error);
1011
693
  const message =
1012
694
  error instanceof Error && error.message
@@ -1015,363 +697,68 @@ export default function Form({
1015
697
  toast.error(message);
1016
698
  } finally {
1017
699
  setIsLoading(false);
1018
- if (isPopup) {
1019
- onClose?.();
1020
- }
1021
700
  }
1022
701
  };
1023
702
 
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
-
1291
703
  const isRequestDetailsValid = useCallback(() => {
1292
704
  if (!requestType) {
1293
705
  return false;
1294
706
  }
1295
707
 
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) {
708
+ // Product-anchor validity gate: ACCESS/ERASURE/CORRECTION require a
709
+ // selected product before submit is allowed. The backend leaves
710
+ // `product_uuid` Optional for back-compat, so we enforce the anchor
711
+ // on the client — a product-scoped case without an anchor would
712
+ // land as an org-level case with no scoping, which violates the
713
+ // 1-form -> 1-case -> 1-product contract.
714
+ if (isProductScopedRequestType(requestType)) {
715
+ if (allGroups.length === 0) {
716
+ // No consent groups for this DP at all — the picker shows its
717
+ // empty state and we block submit. The DP has nothing they can
718
+ // raise a product-scoped case against.
1305
719
  return false;
1306
720
  }
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
- });
721
+ if (!selectedGroup) {
722
+ // Groups exist but the user hasn't picked one. With the
723
+ // single-group auto-select effect this should be rare in
724
+ // practice, but the gate must still reject.
725
+ return false;
1324
726
  }
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
-
1333
- switch (requestType) {
1334
- case RequestTypeEnum.ACCESS:
1335
- return accessPurposes.some(
1336
- (p) => p.selected || p.data_elements.some((de) => de.selected)
1337
- );
1338
-
1339
- case RequestTypeEnum.ERASURE:
1340
- return erasurePurposes.some(
1341
- (p) => p.selected || p.data_elements.some((de) => de.selected)
1342
- );
1343
-
1344
- case RequestTypeEnum.GRIEVANCE:
1345
- return Object.values(selectedGrievances).some(Boolean);
1346
-
1347
- case RequestTypeEnum.CORRECTION:
727
+ if (requestType === RequestTypeEnum.CORRECTION) {
1348
728
  return (
1349
729
  selectedCorrectionElements.length > 0 &&
1350
730
  selectedCorrectionElements.every(
1351
731
  (name) =>
1352
732
  correctionValues[name]?.current?.trim() &&
1353
- correctionValues[name]?.updated?.trim()
733
+ correctionValues[name]?.updated?.trim(),
1354
734
  )
1355
735
  );
736
+ }
737
+ return selectedPurposes.some(
738
+ (p) => p.selected || p.data_elements.some((de) => de.selected),
739
+ );
740
+ }
741
+
742
+ switch (requestType) {
743
+ case RequestTypeEnum.GRIEVANCE:
744
+ return Object.values(selectedGrievances).some(Boolean);
1356
745
 
1357
746
  case RequestTypeEnum.NOMINATION:
1358
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nominationData.nominee_email.trim());
747
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
748
+ nominationData.nominee_email.trim(),
749
+ );
1359
750
 
1360
751
  default:
1361
752
  return false;
1362
753
  }
1363
754
  }, [
1364
755
  requestType,
1365
- useGroupedRender,
1366
- selectedGroups,
1367
- purposesByGroupKey,
1368
- correctionElementsByGroupKey,
1369
- correctionValuesByGroupKey,
1370
- accessPurposes,
1371
- erasurePurposes,
1372
- selectedGrievances,
756
+ allGroups,
757
+ selectedGroup,
758
+ selectedPurposes,
1373
759
  selectedCorrectionElements,
1374
760
  correctionValues,
761
+ selectedGrievances,
1375
762
  nominationData,
1376
763
  ]);
1377
764
 
@@ -1567,13 +954,11 @@ export default function Form({
1567
954
 
1568
955
  return (
1569
956
  <>
1570
- {isSubmitted && batchResult && !isPopup ? (
957
+ {isSubmitted && submittedCase && !isPopup ? (
1571
958
  <RequestSubmit
1572
- result={batchResult}
959
+ result={submittedCase}
1573
960
  contact={formData?.contact}
1574
961
  onCreateRequest={onCreateRequest}
1575
- onRetry={handleRetry}
1576
- retryingGroupKeys={retryingGroupKeys}
1577
962
  />
1578
963
  ) : (
1579
964
  <div>
@@ -1777,7 +1162,7 @@ export default function Form({
1777
1162
  <div style={{
1778
1163
  padding: getVar('--pc-spacing-md'),
1779
1164
  }}>
1780
- {useGroupedRender && isGroupedRequestType(requestType) ? (
1165
+ {isProductScopedRequestType(requestType) ? (
1781
1166
  <div
1782
1167
  style={{
1783
1168
  ...pcStyles.flex,
@@ -1795,22 +1180,16 @@ export default function Form({
1795
1180
  isTranslating={isTranslating}
1796
1181
  />
1797
1182
  )}
1798
- {selectedGroups.length > 0 && (() => {
1799
- const group = selectedGroups[0];
1800
- const key = productGroupKey(group);
1801
- const groupPurposes = purposesByGroupKey.get(key) ?? [];
1183
+ {selectedGroup && (() => {
1184
+ const key = productGroupKey(selectedGroup);
1802
1185
  const isCorrection =
1803
1186
  requestType === RequestTypeEnum.CORRECTION;
1804
1187
  const groupDataElements = isCorrection
1805
- ? buildGroupDataElements(group)
1188
+ ? buildGroupDataElements(selectedGroup)
1806
1189
  : [];
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 ??
1190
+ const nominatorLabel = selectedGroup.nominator
1191
+ ? selectedGroup.nominator.name ??
1192
+ selectedGroup.nominator.email ??
1814
1193
  ''
1815
1194
  : '';
1816
1195
 
@@ -1828,9 +1207,9 @@ export default function Form({
1828
1207
  color="primary"
1829
1208
  className="pc-product-purpose-panel-title"
1830
1209
  >
1831
- {group.product_name}
1210
+ {selectedGroup.product_name}
1832
1211
  </PCText>
1833
- {group.nominator && (
1212
+ {selectedGroup.nominator && (
1834
1213
  <PCText
1835
1214
  as="span"
1836
1215
  size="xs"
@@ -1860,22 +1239,16 @@ export default function Form({
1860
1239
  {isCorrection ? (
1861
1240
  <CorrectionDataSection
1862
1241
  dataElements={groupDataElements}
1863
- selected={groupCorrectionSelected}
1864
- setSelected={(values) =>
1865
- setCorrectionElementsForGroup(key, values)
1866
- }
1867
- correctionValues={groupCorrectionValues}
1868
- setCorrectionValues={(values) =>
1869
- setCorrectionValuesForGroup(key, values)
1870
- }
1242
+ selected={selectedCorrectionElements}
1243
+ setSelected={setSelectedCorrectionElements}
1244
+ correctionValues={correctionValues}
1245
+ setCorrectionValues={setCorrectionValues}
1871
1246
  isTranslating={isTranslating}
1872
1247
  />
1873
1248
  ) : (
1874
1249
  <PurposeSelectionSection
1875
- purposes={groupPurposes}
1876
- setPurposes={(updated) =>
1877
- setPurposesForGroup(key, updated)
1878
- }
1250
+ purposes={selectedPurposes}
1251
+ setPurposes={setSelectedPurposes}
1879
1252
  requestType={requestType}
1880
1253
  isTranslating={isTranslating}
1881
1254
  />
@@ -1885,31 +1258,6 @@ export default function Form({
1885
1258
  );
1886
1259
  })()}
1887
1260
  </div>
1888
- ) : requestType === RequestTypeEnum.ACCESS ||
1889
- requestType === RequestTypeEnum.ERASURE ? (
1890
- <PurposeSelectionSection
1891
- purposes={
1892
- requestType === RequestTypeEnum.ACCESS
1893
- ? accessPurposes
1894
- : erasurePurposes
1895
- }
1896
- setPurposes={
1897
- requestType === RequestTypeEnum.ACCESS
1898
- ? setAccessPurposes
1899
- : setErasurePurposes
1900
- }
1901
- requestType={requestType}
1902
- isTranslating={isTranslating}
1903
- />
1904
- ) : requestType === RequestTypeEnum.CORRECTION ? (
1905
- <CorrectionDataSection
1906
- dataElements={uniqueDataElements}
1907
- selected={selectedCorrectionElements}
1908
- setSelected={setSelectedCorrectionElements}
1909
- correctionValues={correctionValues}
1910
- setCorrectionValues={setCorrectionValues}
1911
- isTranslating={isTranslating}
1912
- />
1913
1261
  ) : requestType === RequestTypeEnum.GRIEVANCE ? (
1914
1262
  <GrievanceDataSection
1915
1263
  grievanceOptions={grievanceOptions}