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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.turbo/turbo-build.log +10 -9
  2. package/CHANGELOG.md +12 -0
  3. package/dist/index.js +2 -4
  4. package/dist/index.mjs +2 -4
  5. package/dist/privacy-center.d.mts +56 -2
  6. package/dist/privacy-center.d.ts +56 -2
  7. package/dist/privacy-center.js +4840 -1874
  8. package/dist/privacy-center.mjs +5124 -2158
  9. package/package.json +1 -1
  10. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +2 -4
  11. package/src/RedactoNoticeConsent/api/types.ts +1 -0
  12. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +3 -1
  13. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  14. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
  15. package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
  16. package/src/RedactoPrivacyCenter/api/client.ts +79 -0
  17. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +19 -13
  18. package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +25 -0
  19. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +39 -30
  20. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +2 -0
  21. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +10 -3
  22. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +21 -6
  23. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
  24. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +12 -0
  25. package/src/RedactoPrivacyCenter/components/Form.tsx +843 -162
  26. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
  27. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +284 -71
  28. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
  29. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
  30. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
  31. package/src/RedactoPrivacyCenter/components/types.ts +26 -2
  32. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  33. package/src/RedactoPrivacyCenter/lib/api.ts +95 -1
  34. package/src/RedactoPrivacyCenter/lib/constants.ts +17 -0
  35. package/src/RedactoPrivacyCenter/lib/types.ts +115 -0
  36. package/src/RedactoPrivacyCenter/locales/as/translation.json +40 -1
  37. package/src/RedactoPrivacyCenter/locales/bho/translation.json +40 -1
  38. package/src/RedactoPrivacyCenter/locales/bn/translation.json +40 -1
  39. package/src/RedactoPrivacyCenter/locales/brx/translation.json +40 -1
  40. package/src/RedactoPrivacyCenter/locales/doi/translation.json +40 -1
  41. package/src/RedactoPrivacyCenter/locales/en/translation.json +46 -1
  42. package/src/RedactoPrivacyCenter/locales/gom/translation.json +40 -1
  43. package/src/RedactoPrivacyCenter/locales/gu/translation.json +40 -1
  44. package/src/RedactoPrivacyCenter/locales/hi/translation.json +40 -1
  45. package/src/RedactoPrivacyCenter/locales/kn/translation.json +40 -1
  46. package/src/RedactoPrivacyCenter/locales/ks/translation.json +40 -1
  47. package/src/RedactoPrivacyCenter/locales/mai/translation.json +40 -1
  48. package/src/RedactoPrivacyCenter/locales/ml/translation.json +40 -1
  49. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +40 -1
  50. package/src/RedactoPrivacyCenter/locales/mr/translation.json +40 -1
  51. package/src/RedactoPrivacyCenter/locales/ne/translation.json +40 -1
  52. package/src/RedactoPrivacyCenter/locales/or/translation.json +40 -1
  53. package/src/RedactoPrivacyCenter/locales/pa/translation.json +40 -1
  54. package/src/RedactoPrivacyCenter/locales/sa/translation.json +40 -1
  55. package/src/RedactoPrivacyCenter/locales/sat/translation.json +40 -1
  56. package/src/RedactoPrivacyCenter/locales/sd/translation.json +40 -1
  57. package/src/RedactoPrivacyCenter/locales/ta/translation.json +40 -1
  58. package/src/RedactoPrivacyCenter/locales/te/translation.json +40 -1
  59. package/src/RedactoPrivacyCenter/locales/ur/translation.json +40 -1
  60. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
  61. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +3 -1
  62. package/tests/Form.test.tsx +1170 -0
  63. package/tests/ProductSelectionSection.test.tsx +293 -0
  64. package/tests/RequestSubmit.test.tsx +295 -0
@@ -0,0 +1,293 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+
4
+ vi.mock('react-i18next', () => {
5
+ const en: Record<string, string> = {
6
+ activePurposes_one: '{{count}} active purpose',
7
+ activePurposes_other: '{{count}} active purposes',
8
+ noProductsAvailable: 'No products available',
9
+ noProductsAvailableDescription: 'You have no consents to manage yet.',
10
+ nominationChipSuffix: '· {{nominator}}',
11
+ selectProductsForRequest:
12
+ 'Select a product for your {{requestType}} request',
13
+ selectProductsForRequestGeneric: 'Select a product to continue',
14
+ };
15
+ const interpolate = (template: string, vars?: Record<string, unknown>) =>
16
+ template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
17
+ const value = vars?.[key];
18
+ return value === undefined || value === null ? '' : String(value);
19
+ });
20
+ const t = (key: string, opts?: Record<string, unknown>) => {
21
+ const count = typeof opts?.count === 'number' ? opts.count : undefined;
22
+ const pluralKey =
23
+ count !== undefined
24
+ ? count === 1
25
+ ? `${key}_one`
26
+ : `${key}_other`
27
+ : key;
28
+ const template = en[pluralKey] ?? en[key] ?? key;
29
+ return interpolate(template, opts);
30
+ };
31
+ return {
32
+ useTranslation: () => ({ t, i18n: { language: 'en' } }),
33
+ };
34
+ });
35
+
36
+ import {
37
+ ProductSelectionSection,
38
+ productGroupKey,
39
+ } from '../src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection';
40
+ import type {
41
+ FormProductGroup,
42
+ NominatorInfo,
43
+ } from '../src/RedactoPrivacyCenter/lib/types';
44
+
45
+ const makeGroup = (
46
+ overrides: Partial<FormProductGroup> = {},
47
+ ): FormProductGroup => ({
48
+ product_uuid: 'prod-1',
49
+ product_name: 'Acme Loyalty',
50
+ product_description: null,
51
+ nominator: null,
52
+ purposes: [],
53
+ ...overrides,
54
+ });
55
+
56
+ const makeNominator = (overrides: Partial<NominatorInfo> = {}): NominatorInfo => ({
57
+ org_user_id: 'org-user-1',
58
+ name: 'Jane Doe',
59
+ uuid: 'nom-uuid-1',
60
+ email: 'jane@example.com',
61
+ ...overrides,
62
+ });
63
+
64
+ describe('ProductSelectionSection', () => {
65
+ it('renders direct groups as pill chips in a wrapping row', () => {
66
+ const direct = makeGroup({
67
+ product_uuid: 'prod-direct',
68
+ product_name: 'Acme Loyalty',
69
+ purposes: [
70
+ {
71
+ uuid: 'p1',
72
+ data_elements: [{ uuid: 'de1', selected: true }],
73
+ } as never,
74
+ {
75
+ uuid: 'p2',
76
+ data_elements: [{ uuid: 'de2', selected: true }],
77
+ } as never,
78
+ ],
79
+ });
80
+ const second = makeGroup({
81
+ product_uuid: 'prod-direct-2',
82
+ product_name: 'Acme Rewards',
83
+ });
84
+
85
+ render(
86
+ <ProductSelectionSection
87
+ directGroups={[direct, second]}
88
+ nominatedGroups={[]}
89
+ selectedProductKey={null}
90
+ onSelect={vi.fn()}
91
+ />,
92
+ );
93
+
94
+ expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
95
+ expect(screen.getByText('Acme Rewards')).toBeInTheDocument();
96
+ expect(screen.getByTestId('product-selection-chips')).toHaveClass(
97
+ 'pc-product-select-chips',
98
+ );
99
+ expect(screen.getByText('2 active purposes')).toBeInTheDocument();
100
+ });
101
+
102
+ it('marks the selected chip with aria-selected and selected style class', () => {
103
+ const direct = makeGroup({
104
+ product_uuid: 'prod-direct',
105
+ product_name: 'Acme Loyalty',
106
+ });
107
+
108
+ render(
109
+ <ProductSelectionSection
110
+ directGroups={[direct]}
111
+ nominatedGroups={[]}
112
+ selectedProductKey={productGroupKey(direct)}
113
+ onSelect={vi.fn()}
114
+ />,
115
+ );
116
+
117
+ const chip = screen.getByTestId('product-chip-prod-direct');
118
+ expect(chip).toHaveClass('pc-product-select-chip');
119
+ expect(chip).toHaveClass('pc-product-select-chip--selected');
120
+ expect(chip).toHaveAttribute('aria-selected', 'true');
121
+ });
122
+
123
+ it('renders nominated groups with an inline nominator suffix on the chip', () => {
124
+ const nominated = makeGroup({
125
+ product_uuid: 'prod-nom',
126
+ product_name: 'Partner Bank',
127
+ nominator: makeNominator({ name: 'Alice Johnson' }),
128
+ purposes: [
129
+ {
130
+ uuid: 'p1',
131
+ data_elements: [{ uuid: 'de1', selected: true }],
132
+ } as never,
133
+ ],
134
+ });
135
+ const otherDirect = makeGroup({
136
+ product_uuid: 'prod-direct',
137
+ product_name: 'Acme Loyalty',
138
+ });
139
+
140
+ render(
141
+ <ProductSelectionSection
142
+ directGroups={[otherDirect]}
143
+ nominatedGroups={[nominated]}
144
+ selectedProductKey={null}
145
+ onSelect={vi.fn()}
146
+ />,
147
+ );
148
+
149
+ expect(screen.getByText('Partner Bank')).toBeInTheDocument();
150
+ expect(screen.getByText('· Alice Johnson')).toBeInTheDocument();
151
+ expect(screen.getByText('1 active purpose')).toBeInTheDocument();
152
+ });
153
+
154
+ it('falls back to nominator email in the chip suffix when name is null', () => {
155
+ const nominated = makeGroup({
156
+ product_uuid: 'prod-nom',
157
+ product_name: 'Partner Bank',
158
+ nominator: makeNominator({ name: null, email: 'fallback@example.com' }),
159
+ });
160
+ const otherDirect = makeGroup({ product_uuid: 'prod-direct' });
161
+
162
+ render(
163
+ <ProductSelectionSection
164
+ directGroups={[otherDirect]}
165
+ nominatedGroups={[nominated]}
166
+ selectedProductKey={null}
167
+ onSelect={vi.fn()}
168
+ />,
169
+ );
170
+
171
+ expect(screen.getByText('· fallback@example.com')).toBeInTheDocument();
172
+ });
173
+
174
+ it('invokes onSelect with the correct key when a chip is clicked', () => {
175
+ const direct = makeGroup({
176
+ product_uuid: 'prod-direct',
177
+ product_name: 'Acme Loyalty',
178
+ });
179
+ const nominated = makeGroup({
180
+ product_uuid: 'prod-nom',
181
+ product_name: 'Partner Bank',
182
+ nominator: makeNominator(),
183
+ });
184
+ const onSelect = vi.fn();
185
+
186
+ render(
187
+ <ProductSelectionSection
188
+ directGroups={[direct]}
189
+ nominatedGroups={[nominated]}
190
+ selectedProductKey={null}
191
+ onSelect={onSelect}
192
+ />,
193
+ );
194
+
195
+ fireEvent.click(screen.getByTestId('product-chip-prod-direct'));
196
+ fireEvent.click(screen.getByTestId(`product-chip-${productGroupKey(nominated)}`));
197
+
198
+ expect(onSelect).toHaveBeenCalledWith(
199
+ productGroupKey(direct),
200
+ expect.objectContaining({ product_uuid: 'prod-direct' }),
201
+ );
202
+ expect(onSelect).toHaveBeenCalledWith(
203
+ productGroupKey(nominated),
204
+ expect.objectContaining({ product_uuid: 'prod-nom' }),
205
+ );
206
+ expect(onSelect).toHaveBeenCalledTimes(2);
207
+ });
208
+
209
+ it('clears selection when the already-selected chip is clicked again', () => {
210
+ const direct = makeGroup({
211
+ product_uuid: 'prod-direct',
212
+ product_name: 'Acme Loyalty',
213
+ });
214
+ const second = makeGroup({
215
+ product_uuid: 'prod-direct-2',
216
+ product_name: 'Acme Rewards',
217
+ });
218
+ const onSelect = vi.fn();
219
+
220
+ render(
221
+ <ProductSelectionSection
222
+ directGroups={[direct, second]}
223
+ nominatedGroups={[]}
224
+ selectedProductKey={productGroupKey(direct)}
225
+ onSelect={onSelect}
226
+ />,
227
+ );
228
+
229
+ fireEvent.click(screen.getByTestId('product-chip-prod-direct'));
230
+
231
+ expect(onSelect).toHaveBeenCalledWith(null, null);
232
+ });
233
+
234
+ it('auto-selects and locks the only available group', () => {
235
+ const only = makeGroup({
236
+ product_uuid: 'prod-only',
237
+ product_name: 'Sole Product',
238
+ });
239
+ const onSelect = vi.fn();
240
+
241
+ render(
242
+ <ProductSelectionSection
243
+ directGroups={[only]}
244
+ nominatedGroups={[]}
245
+ selectedProductKey={null}
246
+ onSelect={onSelect}
247
+ />,
248
+ );
249
+
250
+ expect(onSelect).toHaveBeenCalledTimes(1);
251
+ expect(onSelect).toHaveBeenCalledWith(
252
+ productGroupKey(only),
253
+ expect.objectContaining({ product_uuid: 'prod-only' }),
254
+ );
255
+
256
+ const chip = screen.getByTestId('product-chip-prod-only') as HTMLButtonElement;
257
+ expect(chip).toHaveAttribute('aria-selected', 'true');
258
+ expect(chip.disabled).toBe(true);
259
+ });
260
+
261
+ it('renders an empty state when both arrays are empty', () => {
262
+ render(
263
+ <ProductSelectionSection
264
+ directGroups={[]}
265
+ nominatedGroups={[]}
266
+ selectedProductKey={null}
267
+ onSelect={vi.fn()}
268
+ />,
269
+ );
270
+
271
+ expect(screen.getByText('No products available')).toBeInTheDocument();
272
+ expect(
273
+ screen.getByText('You have no consents to manage yet.'),
274
+ ).toBeInTheDocument();
275
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
276
+ });
277
+
278
+ it('distinguishes direct vs nominated chips on the same product via key helper', () => {
279
+ const direct = makeGroup({
280
+ product_uuid: 'prod-shared',
281
+ product_name: 'Shared Product',
282
+ });
283
+ const nominated = makeGroup({
284
+ product_uuid: 'prod-shared',
285
+ product_name: 'Shared Product',
286
+ nominator: makeNominator({ uuid: 'nom-uuid-x' }),
287
+ });
288
+
289
+ expect(productGroupKey(direct)).toBe('prod-shared');
290
+ expect(productGroupKey(nominated)).toBe('prod-shared:nom-uuid-x');
291
+ expect(productGroupKey(direct)).not.toBe(productGroupKey(nominated));
292
+ });
293
+ });
@@ -0,0 +1,295 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+
4
+ // react-i18next is an optional peer dep that is not installed in this
5
+ // package's node_modules. Stub it with a minimal `useTranslation` that
6
+ // resolves the en/translation.json keys we use here, with interpolation
7
+ // and `_one` / `_other` plural suffix support.
8
+ vi.mock('react-i18next', () => {
9
+ const en: Record<string, string> = {
10
+ requestSubmittedSuccessfully: 'Request Submitted Successfully',
11
+ thankYouForSubmittingYourRequest: 'Thank you for submitting your request',
12
+ caseID: 'Case ID',
13
+ saveThisID: 'Please save this ID for future reference',
14
+ casesCreatedCount_one: '{{count}} case created',
15
+ casesCreatedCount_other: '{{count}} cases created',
16
+ someRequestsFailed: 'Some requests failed',
17
+ retry: 'Retry',
18
+ retrying: 'Retrying...',
19
+ confirmationSentTo: 'Confirmation sent to',
20
+ processRequest: 'We will process your request as per laws',
21
+ backToHome: 'Back to homepage',
22
+ yourDataIsSafeWithUs: 'Your data is safe with us',
23
+ erasureConsentRevokeNotice:
24
+ 'Your consent for {{purposes}} will be revoked once fulfilled.',
25
+ };
26
+ const interpolate = (template: string, vars?: Record<string, unknown>) =>
27
+ template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
28
+ const value = vars?.[key];
29
+ return value === undefined || value === null ? '' : String(value);
30
+ });
31
+ const t = (key: string, opts?: Record<string, unknown>) => {
32
+ const count = typeof opts?.count === 'number' ? opts.count : undefined;
33
+ const pluralKey =
34
+ count !== undefined
35
+ ? count === 1
36
+ ? `${key}_one`
37
+ : `${key}_other`
38
+ : key;
39
+ const template = en[pluralKey] ?? en[key] ?? key;
40
+ return interpolate(template, opts);
41
+ };
42
+ return {
43
+ useTranslation: () => ({ t, i18n: { language: 'en' } }),
44
+ };
45
+ });
46
+
47
+ // `lucide-react` is also a peer dep — stub the icon used by RequestSubmit.
48
+ vi.mock('lucide-react', () => ({
49
+ Mail: () => null,
50
+ }));
51
+
52
+ import RequestSubmit from '../src/RedactoPrivacyCenter/components/RequestSubmit';
53
+ import type { BatchCaseSubmitResult } from '../src/RedactoPrivacyCenter/lib/types';
54
+
55
+ const makeResult = (
56
+ overrides: Partial<BatchCaseSubmitResult> = {},
57
+ ): BatchCaseSubmitResult => ({
58
+ succeeded: [],
59
+ failed: [],
60
+ ...overrides,
61
+ });
62
+
63
+ const singleSucceeded: BatchCaseSubmitResult = {
64
+ succeeded: [
65
+ {
66
+ group_key: '',
67
+ product_uuid: '',
68
+ product_name: '',
69
+ case_id: 'CASE-ONLY',
70
+ uuid: 'uuid-only',
71
+ },
72
+ ],
73
+ failed: [],
74
+ };
75
+
76
+ const multiSucceeded: BatchCaseSubmitResult = {
77
+ succeeded: [
78
+ {
79
+ group_key: 'prod-1',
80
+ product_uuid: 'prod-1',
81
+ product_name: 'Acme Loyalty',
82
+ case_id: 'CASE-1',
83
+ uuid: 'uuid-1',
84
+ },
85
+ {
86
+ group_key: 'prod-2',
87
+ product_uuid: 'prod-2',
88
+ product_name: 'Acme Rewards',
89
+ case_id: 'CASE-2',
90
+ uuid: 'uuid-2',
91
+ },
92
+ ],
93
+ failed: [],
94
+ };
95
+
96
+ const partialResult: BatchCaseSubmitResult = {
97
+ succeeded: [
98
+ {
99
+ group_key: 'prod-ok',
100
+ product_uuid: 'prod-ok',
101
+ product_name: 'Working Product',
102
+ case_id: 'CASE-OK',
103
+ uuid: 'uuid-ok',
104
+ },
105
+ ],
106
+ failed: [
107
+ {
108
+ group_key: 'prod-fail',
109
+ product_uuid: 'prod-fail',
110
+ product_name: 'Broken Product',
111
+ error: 'Network unreachable',
112
+ },
113
+ ],
114
+ };
115
+
116
+ describe('RequestSubmit', () => {
117
+ it('renders a single-case success card (legacy / nomination / grievance shape)', () => {
118
+ render(
119
+ <RequestSubmit
120
+ result={singleSucceeded}
121
+ contact="alice@example.com"
122
+ onCreateRequest={vi.fn()}
123
+ />,
124
+ );
125
+
126
+ // Single-case branch: one "Case ID: ..." row, no plural header, no
127
+ // failure block.
128
+ expect(
129
+ screen.getByText('Request Submitted Successfully'),
130
+ ).toBeInTheDocument();
131
+ expect(screen.getByText(/Case ID:\s*CASE-ONLY/)).toBeInTheDocument();
132
+ expect(screen.queryByText(/cases created/)).not.toBeInTheDocument();
133
+ expect(
134
+ screen.queryByText('Some requests failed'),
135
+ ).not.toBeInTheDocument();
136
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
137
+ });
138
+
139
+ it('renders a multi-case success list with product names and case IDs', () => {
140
+ render(
141
+ <RequestSubmit
142
+ result={multiSucceeded}
143
+ contact="alice@example.com"
144
+ onCreateRequest={vi.fn()}
145
+ />,
146
+ );
147
+
148
+ // Header reflects pluralised count.
149
+ expect(screen.getByText('2 cases created')).toBeInTheDocument();
150
+ // Each entry renders product name + case id.
151
+ expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
152
+ expect(screen.getByText('Acme Rewards')).toBeInTheDocument();
153
+ expect(screen.getByText(/Case ID:\s*CASE-1/)).toBeInTheDocument();
154
+ expect(screen.getByText(/Case ID:\s*CASE-2/)).toBeInTheDocument();
155
+ // No failure block when all succeeded.
156
+ expect(
157
+ screen.queryByText('Some requests failed'),
158
+ ).not.toBeInTheDocument();
159
+ });
160
+
161
+ it('renders a failed-cases block with per-row retry buttons when failures exist', () => {
162
+ render(
163
+ <RequestSubmit
164
+ result={partialResult}
165
+ contact="alice@example.com"
166
+ onCreateRequest={vi.fn()}
167
+ onRetry={vi.fn()}
168
+ />,
169
+ );
170
+
171
+ // Succeeded row still present.
172
+ expect(screen.getByText('Working Product')).toBeInTheDocument();
173
+ expect(screen.getByText(/Case ID:\s*CASE-OK/)).toBeInTheDocument();
174
+
175
+ // Failure block with product name, error message, retry button.
176
+ expect(screen.getByText('Some requests failed')).toBeInTheDocument();
177
+ expect(screen.getByText('Broken Product')).toBeInTheDocument();
178
+ expect(screen.getByText('Network unreachable')).toBeInTheDocument();
179
+ expect(
180
+ screen.getByRole('button', { name: 'Retry' }),
181
+ ).toBeInTheDocument();
182
+ });
183
+
184
+ it('does not render retry buttons when onRetry is not provided', () => {
185
+ render(
186
+ <RequestSubmit
187
+ result={partialResult}
188
+ contact="alice@example.com"
189
+ onCreateRequest={vi.fn()}
190
+ />,
191
+ );
192
+
193
+ expect(screen.getByText('Some requests failed')).toBeInTheDocument();
194
+ expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull();
195
+ });
196
+
197
+ it('invokes onRetry with the failed entry group_key when retry is clicked', () => {
198
+ const onRetry = vi.fn();
199
+ render(
200
+ <RequestSubmit
201
+ result={partialResult}
202
+ contact="alice@example.com"
203
+ onCreateRequest={vi.fn()}
204
+ onRetry={onRetry}
205
+ />,
206
+ );
207
+
208
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
209
+
210
+ expect(onRetry).toHaveBeenCalledTimes(1);
211
+ expect(onRetry).toHaveBeenCalledWith('prod-fail');
212
+ });
213
+
214
+ it('shows the Retrying… label and disables the button for rows in retryingGroupKeys', () => {
215
+ const onRetry = vi.fn();
216
+ render(
217
+ <RequestSubmit
218
+ result={partialResult}
219
+ contact="alice@example.com"
220
+ onCreateRequest={vi.fn()}
221
+ onRetry={onRetry}
222
+ retryingGroupKeys={new Set(['prod-fail'])}
223
+ />,
224
+ );
225
+
226
+ const button = screen.getByRole('button', {
227
+ name: /Retrying/,
228
+ }) as HTMLButtonElement;
229
+ expect(button).toBeInTheDocument();
230
+ expect(button.disabled).toBe(true);
231
+ });
232
+
233
+ it('invokes onCreateRequest when "Back to homepage" is clicked', () => {
234
+ const onCreateRequest = vi.fn();
235
+ render(
236
+ <RequestSubmit
237
+ result={singleSucceeded}
238
+ contact="alice@example.com"
239
+ onCreateRequest={onCreateRequest}
240
+ />,
241
+ );
242
+
243
+ // Two rendered "Back to homepage" buttons exist for the responsive
244
+ // mobile/desktop variants. Clicking either should fire the handler.
245
+ const buttons = screen.getAllByRole('button', { name: 'Back to homepage' });
246
+ expect(buttons.length).toBeGreaterThanOrEqual(1);
247
+ fireEvent.click(buttons[0]);
248
+ expect(onCreateRequest).toHaveBeenCalledTimes(1);
249
+ });
250
+
251
+ it('renders the erasure-revoke notice when revokeConsentOnFulfilment is true', () => {
252
+ render(
253
+ <RequestSubmit
254
+ result={singleSucceeded}
255
+ contact="alice@example.com"
256
+ onCreateRequest={vi.fn()}
257
+ revokeConsentOnFulfilment
258
+ revokedPurposeNames="Marketing, Analytics"
259
+ />,
260
+ );
261
+
262
+ expect(
263
+ screen.getByText(/Marketing, Analytics/),
264
+ ).toBeInTheDocument();
265
+ });
266
+
267
+ it('renders nothing in the case-list area when succeeded is empty (failures-only)', () => {
268
+ // Edge case: every product failed. Header still shows the success
269
+ // banner (parent decides whether to render); the case-list area is
270
+ // omitted and the failure block is the only middle content.
271
+ const failuresOnly = makeResult({
272
+ failed: [
273
+ {
274
+ group_key: 'prod-a',
275
+ product_uuid: 'prod-a',
276
+ product_name: 'Product A',
277
+ error: 'Timeout',
278
+ },
279
+ ],
280
+ });
281
+ render(
282
+ <RequestSubmit
283
+ result={failuresOnly}
284
+ contact="alice@example.com"
285
+ onCreateRequest={vi.fn()}
286
+ onRetry={vi.fn()}
287
+ />,
288
+ );
289
+
290
+ expect(screen.queryByText(/Case ID:/)).not.toBeInTheDocument();
291
+ expect(screen.queryByText(/cases created/)).not.toBeInTheDocument();
292
+ expect(screen.getByText('Some requests failed')).toBeInTheDocument();
293
+ expect(screen.getByText('Product A')).toBeInTheDocument();
294
+ });
295
+ });