@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
|
@@ -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
|
+
});
|