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

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 (51) hide show
  1. package/.turbo/turbo-build.log +15 -10
  2. package/CHANGELOG.md +12 -0
  3. package/dist/privacy-center.d.mts +51 -1
  4. package/dist/privacy-center.d.ts +51 -1
  5. package/dist/privacy-center.js +3463 -1148
  6. package/dist/privacy-center.mjs +3399 -1084
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  9. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
  10. package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
  11. package/src/RedactoPrivacyCenter/api/client.ts +79 -0
  12. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
  13. package/src/RedactoPrivacyCenter/components/Form.tsx +402 -259
  14. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
  15. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +95 -57
  16. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
  17. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
  18. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
  19. package/src/RedactoPrivacyCenter/components/types.ts +20 -2
  20. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  21. package/src/RedactoPrivacyCenter/lib/api.ts +67 -8
  22. package/src/RedactoPrivacyCenter/lib/constants.ts +5 -0
  23. package/src/RedactoPrivacyCenter/lib/types.ts +74 -0
  24. package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
  25. package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
  26. package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
  27. package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
  28. package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
  29. package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
  30. package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
  31. package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
  32. package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
  33. package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
  34. package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
  35. package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
  36. package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
  37. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
  38. package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
  39. package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
  40. package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
  41. package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
  42. package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
  43. package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
  44. package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
  45. package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
  46. package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
  47. package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
  48. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
  49. package/tests/Form.test.tsx +1144 -0
  50. package/tests/ProductSelectionSection.test.tsx +328 -0
  51. package/tests/RequestSubmit.test.tsx +135 -0
@@ -0,0 +1,328 @@
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}} purpose',
7
+ activePurposes_other: '{{count}} 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 purposes')).toBeInTheDocument();
100
+ });
101
+
102
+ it('chip count reflects total raisable purposes regardless of status', () => {
103
+ // Per TECH-4089, withdrawn purposes still appear in the picker (revoking
104
+ // consent is not deletion — DP must still be able to raise requests for
105
+ // them). The chip label is a plain count of raisable purposes — no
106
+ // active/withdrawn distinction is surfaced in the label.
107
+ const direct = makeGroup({
108
+ product_uuid: 'prod-mixed',
109
+ product_name: 'Acme Loyalty',
110
+ purposes: [
111
+ {
112
+ uuid: 'p-active',
113
+ data_elements: [{ uuid: 'de-a', selected: true }],
114
+ status: 'ACTIVE',
115
+ } as never,
116
+ {
117
+ uuid: 'p-withdrawn',
118
+ data_elements: [{ uuid: 'de-w', selected: false }],
119
+ status: 'WITHDRAW',
120
+ } as never,
121
+ ],
122
+ });
123
+
124
+ render(
125
+ <ProductSelectionSection
126
+ directGroups={[direct]}
127
+ nominatedGroups={[]}
128
+ selectedProductKey={null}
129
+ onSelect={vi.fn()}
130
+ />,
131
+ );
132
+
133
+ expect(screen.getByText('2 purposes')).toBeInTheDocument();
134
+ expect(screen.queryByText(/active/)).not.toBeInTheDocument();
135
+ });
136
+
137
+ it('marks the selected chip with aria-selected and selected style class', () => {
138
+ const direct = makeGroup({
139
+ product_uuid: 'prod-direct',
140
+ product_name: 'Acme Loyalty',
141
+ });
142
+
143
+ render(
144
+ <ProductSelectionSection
145
+ directGroups={[direct]}
146
+ nominatedGroups={[]}
147
+ selectedProductKey={productGroupKey(direct)}
148
+ onSelect={vi.fn()}
149
+ />,
150
+ );
151
+
152
+ const chip = screen.getByTestId('product-chip-prod-direct');
153
+ expect(chip).toHaveClass('pc-product-select-chip');
154
+ expect(chip).toHaveClass('pc-product-select-chip--selected');
155
+ expect(chip).toHaveAttribute('aria-selected', 'true');
156
+ });
157
+
158
+ it('renders nominated groups with an inline nominator suffix on the chip', () => {
159
+ const nominated = makeGroup({
160
+ product_uuid: 'prod-nom',
161
+ product_name: 'Partner Bank',
162
+ nominator: makeNominator({ name: 'Alice Johnson' }),
163
+ purposes: [
164
+ {
165
+ uuid: 'p1',
166
+ data_elements: [{ uuid: 'de1', selected: true }],
167
+ } as never,
168
+ ],
169
+ });
170
+ const otherDirect = makeGroup({
171
+ product_uuid: 'prod-direct',
172
+ product_name: 'Acme Loyalty',
173
+ });
174
+
175
+ render(
176
+ <ProductSelectionSection
177
+ directGroups={[otherDirect]}
178
+ nominatedGroups={[nominated]}
179
+ selectedProductKey={null}
180
+ onSelect={vi.fn()}
181
+ />,
182
+ );
183
+
184
+ expect(screen.getByText('Partner Bank')).toBeInTheDocument();
185
+ expect(screen.getByText('· Alice Johnson')).toBeInTheDocument();
186
+ expect(screen.getByText('1 purpose')).toBeInTheDocument();
187
+ });
188
+
189
+ it('falls back to nominator email in the chip suffix when name is null', () => {
190
+ const nominated = makeGroup({
191
+ product_uuid: 'prod-nom',
192
+ product_name: 'Partner Bank',
193
+ nominator: makeNominator({ name: null, email: 'fallback@example.com' }),
194
+ });
195
+ const otherDirect = makeGroup({ product_uuid: 'prod-direct' });
196
+
197
+ render(
198
+ <ProductSelectionSection
199
+ directGroups={[otherDirect]}
200
+ nominatedGroups={[nominated]}
201
+ selectedProductKey={null}
202
+ onSelect={vi.fn()}
203
+ />,
204
+ );
205
+
206
+ expect(screen.getByText('· fallback@example.com')).toBeInTheDocument();
207
+ });
208
+
209
+ it('invokes onSelect with the correct key when a chip is clicked', () => {
210
+ const direct = makeGroup({
211
+ product_uuid: 'prod-direct',
212
+ product_name: 'Acme Loyalty',
213
+ });
214
+ const nominated = makeGroup({
215
+ product_uuid: 'prod-nom',
216
+ product_name: 'Partner Bank',
217
+ nominator: makeNominator(),
218
+ });
219
+ const onSelect = vi.fn();
220
+
221
+ render(
222
+ <ProductSelectionSection
223
+ directGroups={[direct]}
224
+ nominatedGroups={[nominated]}
225
+ selectedProductKey={null}
226
+ onSelect={onSelect}
227
+ />,
228
+ );
229
+
230
+ fireEvent.click(screen.getByTestId('product-chip-prod-direct'));
231
+ fireEvent.click(screen.getByTestId(`product-chip-${productGroupKey(nominated)}`));
232
+
233
+ expect(onSelect).toHaveBeenCalledWith(
234
+ productGroupKey(direct),
235
+ expect.objectContaining({ product_uuid: 'prod-direct' }),
236
+ );
237
+ expect(onSelect).toHaveBeenCalledWith(
238
+ productGroupKey(nominated),
239
+ expect.objectContaining({ product_uuid: 'prod-nom' }),
240
+ );
241
+ expect(onSelect).toHaveBeenCalledTimes(2);
242
+ });
243
+
244
+ it('clears selection when the already-selected chip is clicked again', () => {
245
+ const direct = makeGroup({
246
+ product_uuid: 'prod-direct',
247
+ product_name: 'Acme Loyalty',
248
+ });
249
+ const second = makeGroup({
250
+ product_uuid: 'prod-direct-2',
251
+ product_name: 'Acme Rewards',
252
+ });
253
+ const onSelect = vi.fn();
254
+
255
+ render(
256
+ <ProductSelectionSection
257
+ directGroups={[direct, second]}
258
+ nominatedGroups={[]}
259
+ selectedProductKey={productGroupKey(direct)}
260
+ onSelect={onSelect}
261
+ />,
262
+ );
263
+
264
+ fireEvent.click(screen.getByTestId('product-chip-prod-direct'));
265
+
266
+ expect(onSelect).toHaveBeenCalledWith(null, null);
267
+ });
268
+
269
+ it('auto-selects and locks the only available group', () => {
270
+ const only = makeGroup({
271
+ product_uuid: 'prod-only',
272
+ product_name: 'Sole Product',
273
+ });
274
+ const onSelect = vi.fn();
275
+
276
+ render(
277
+ <ProductSelectionSection
278
+ directGroups={[only]}
279
+ nominatedGroups={[]}
280
+ selectedProductKey={null}
281
+ onSelect={onSelect}
282
+ />,
283
+ );
284
+
285
+ expect(onSelect).toHaveBeenCalledTimes(1);
286
+ expect(onSelect).toHaveBeenCalledWith(
287
+ productGroupKey(only),
288
+ expect.objectContaining({ product_uuid: 'prod-only' }),
289
+ );
290
+
291
+ const chip = screen.getByTestId('product-chip-prod-only') as HTMLButtonElement;
292
+ expect(chip).toHaveAttribute('aria-selected', 'true');
293
+ expect(chip.disabled).toBe(true);
294
+ });
295
+
296
+ it('renders an empty state when both arrays are empty', () => {
297
+ render(
298
+ <ProductSelectionSection
299
+ directGroups={[]}
300
+ nominatedGroups={[]}
301
+ selectedProductKey={null}
302
+ onSelect={vi.fn()}
303
+ />,
304
+ );
305
+
306
+ expect(screen.getByText('No products available')).toBeInTheDocument();
307
+ expect(
308
+ screen.getByText('You have no consents to manage yet.'),
309
+ ).toBeInTheDocument();
310
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
311
+ });
312
+
313
+ it('distinguishes direct vs nominated chips on the same product via key helper', () => {
314
+ const direct = makeGroup({
315
+ product_uuid: 'prod-shared',
316
+ product_name: 'Shared Product',
317
+ });
318
+ const nominated = makeGroup({
319
+ product_uuid: 'prod-shared',
320
+ product_name: 'Shared Product',
321
+ nominator: makeNominator({ uuid: 'nom-uuid-x' }),
322
+ });
323
+
324
+ expect(productGroupKey(direct)).toBe('prod-shared');
325
+ expect(productGroupKey(nominated)).toBe('prod-shared:nom-uuid-x');
326
+ expect(productGroupKey(direct)).not.toBe(productGroupKey(nominated));
327
+ });
328
+ });
@@ -0,0 +1,135 @@
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
+ // 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
+ confirmationSentTo: 'Confirmation sent to',
15
+ processRequest: 'We will process your request as per laws',
16
+ backToHome: 'Back to homepage',
17
+ yourDataIsSafeWithUs: 'Your data is safe with us',
18
+ erasureConsentRevokeNotice:
19
+ 'Your consent for {{purposes}} will be revoked once fulfilled.',
20
+ };
21
+ const interpolate = (template: string, vars?: Record<string, unknown>) =>
22
+ template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
23
+ const value = vars?.[key];
24
+ return value === undefined || value === null ? '' : String(value);
25
+ });
26
+ const t = (key: string, opts?: Record<string, unknown>) => {
27
+ const template = en[key] ?? key;
28
+ return interpolate(template, opts);
29
+ };
30
+ return {
31
+ useTranslation: () => ({ t, i18n: { language: 'en' } }),
32
+ };
33
+ });
34
+
35
+ // `lucide-react` is also a peer dep — stub the icon used by RequestSubmit.
36
+ vi.mock('lucide-react', () => ({
37
+ Mail: () => null,
38
+ }));
39
+
40
+ import RequestSubmit from '../src/RedactoPrivacyCenter/components/RequestSubmit';
41
+ import type { RequestSubmitResult } from '../src/RedactoPrivacyCenter/components/types';
42
+
43
+ const productScopedResult: RequestSubmitResult = {
44
+ case_id: 'CASE-PROD',
45
+ uuid: 'uuid-prod',
46
+ product_name: 'Acme Loyalty',
47
+ product_uuid: 'prod-1',
48
+ };
49
+
50
+ const orgLevelResult: RequestSubmitResult = {
51
+ case_id: 'CASE-ORG',
52
+ uuid: 'uuid-org',
53
+ product_name: '',
54
+ product_uuid: '',
55
+ };
56
+
57
+ describe('RequestSubmit', () => {
58
+ it('renders the case id and product label for a product-scoped result', () => {
59
+ render(
60
+ <RequestSubmit
61
+ result={productScopedResult}
62
+ contact="alice@example.com"
63
+ onCreateRequest={vi.fn()}
64
+ />,
65
+ );
66
+
67
+ expect(
68
+ screen.getByText('Request Submitted Successfully'),
69
+ ).toBeInTheDocument();
70
+ expect(screen.getByText(/Case ID:\s*CASE-PROD/)).toBeInTheDocument();
71
+ expect(screen.getByText('Acme Loyalty')).toBeInTheDocument();
72
+ });
73
+
74
+ it('omits the product label when the result has no product anchor', () => {
75
+ render(
76
+ <RequestSubmit
77
+ result={orgLevelResult}
78
+ contact="alice@example.com"
79
+ onCreateRequest={vi.fn()}
80
+ />,
81
+ );
82
+
83
+ expect(screen.getByText(/Case ID:\s*CASE-ORG/)).toBeInTheDocument();
84
+ // Empty product_name means the product line is not rendered.
85
+ expect(screen.queryByText('Acme Loyalty')).not.toBeInTheDocument();
86
+ });
87
+
88
+ it('shows the contact in the confirmation line', () => {
89
+ render(
90
+ <RequestSubmit
91
+ result={productScopedResult}
92
+ contact="alice@example.com"
93
+ onCreateRequest={vi.fn()}
94
+ />,
95
+ );
96
+
97
+ expect(
98
+ screen.getByText(/Confirmation sent to:\s*alice@example\.com/),
99
+ ).toBeInTheDocument();
100
+ });
101
+
102
+ it('invokes onCreateRequest when "Back to homepage" is clicked', () => {
103
+ const onCreateRequest = vi.fn();
104
+ render(
105
+ <RequestSubmit
106
+ result={productScopedResult}
107
+ contact="alice@example.com"
108
+ onCreateRequest={onCreateRequest}
109
+ />,
110
+ );
111
+
112
+ // Two rendered "Back to homepage" buttons exist for the responsive
113
+ // mobile/desktop variants. Clicking either should fire the handler.
114
+ const buttons = screen.getAllByRole('button', { name: 'Back to homepage' });
115
+ expect(buttons.length).toBeGreaterThanOrEqual(1);
116
+ fireEvent.click(buttons[0]);
117
+ expect(onCreateRequest).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it('renders the erasure-revoke notice when revokeConsentOnFulfilment is true', () => {
121
+ render(
122
+ <RequestSubmit
123
+ result={productScopedResult}
124
+ contact="alice@example.com"
125
+ onCreateRequest={vi.fn()}
126
+ revokeConsentOnFulfilment
127
+ revokedPurposeNames="Marketing, Analytics"
128
+ />,
129
+ );
130
+
131
+ expect(
132
+ screen.getByText(/Marketing, Analytics/),
133
+ ).toBeInTheDocument();
134
+ });
135
+ });