@micha.bigler/ui-core-micha 2.5.0 → 2.7.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 (36) hide show
  1. package/.github/workflows/publish.yml +15 -7
  2. package/dist/auth/AuthContext.js +6 -0
  3. package/dist/i18n/notificationsTranslations.js +17 -0
  4. package/dist/i18n/onboardingTranslations.js +20 -0
  5. package/dist/index.js +14 -0
  6. package/dist/notifications/NotificationSettings.js +148 -0
  7. package/dist/notifications/api.js +41 -0
  8. package/dist/notifications/serviceWorker/sw.js +49 -0
  9. package/dist/onboarding/OnboardingProvider.js +133 -0
  10. package/dist/onboarding/OnboardingWizard.js +53 -0
  11. package/dist/onboarding/api.js +10 -0
  12. package/dist/onboarding/stepSelection.js +18 -0
  13. package/dist/onboarding/steps/BrowserPushStep.js +49 -0
  14. package/dist/onboarding/steps/CompleteNameStep.js +37 -0
  15. package/dist/onboarding/steps/CookieConsentStep.js +68 -0
  16. package/package.json +7 -3
  17. package/src/auth/AuthContext.jsx +6 -0
  18. package/src/i18n/notificationsTranslations.ts +17 -0
  19. package/src/i18n/onboardingTranslations.ts +20 -0
  20. package/src/index.js +22 -0
  21. package/src/notifications/NotificationSettings.jsx +189 -0
  22. package/src/notifications/api.js +49 -0
  23. package/src/notifications/serviceWorker/sw.js +50 -0
  24. package/src/onboarding/OnboardingProvider.jsx +153 -0
  25. package/src/onboarding/OnboardingWizard.jsx +71 -0
  26. package/src/onboarding/api.js +13 -0
  27. package/src/onboarding/stepSelection.js +16 -0
  28. package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
  29. package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
  30. package/src/onboarding/steps/CookieConsentStep.jsx +133 -0
  31. package/tests/AuthContext.test.jsx +50 -0
  32. package/tests/CookieConsentStep.test.jsx +69 -0
  33. package/tests/NotificationSettings.test.jsx +82 -0
  34. package/tests/notificationsApi.test.js +41 -0
  35. package/tests/onboardingDescriptors.test.js +25 -0
  36. package/tests/stepSelection.test.js +53 -0
@@ -0,0 +1,82 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const notificationsApi = vi.hoisted(() => ({
7
+ getNotificationPreferences: vi.fn(),
8
+ getVapidPublicKey: vi.fn(),
9
+ patchNotificationPreferences: vi.fn(),
10
+ removePushSubscription: vi.fn(),
11
+ savePushSubscription: vi.fn(),
12
+ urlBase64ToUint8Array: vi.fn(() => new Uint8Array([1, 2, 3])),
13
+ }));
14
+
15
+ vi.mock('../src/notifications/api', () => notificationsApi);
16
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
17
+
18
+ import { NotificationSettings } from '../src/notifications/NotificationSettings';
19
+
20
+ function installPushEnvironment(subscription) {
21
+ const registration = {
22
+ pushManager: {
23
+ getSubscription: vi.fn().mockResolvedValue(subscription),
24
+ subscribe: vi.fn(),
25
+ },
26
+ };
27
+ vi.stubGlobal('navigator', {
28
+ serviceWorker: { ready: Promise.resolve(registration) },
29
+ userAgent: 'Unit Test Browser',
30
+ });
31
+ Object.defineProperty(window, 'PushManager', { value: class PushManager {}, configurable: true });
32
+ return registration;
33
+ }
34
+
35
+ describe('NotificationSettings push toggle', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: false, push_opt_in: false });
39
+ notificationsApi.patchNotificationPreferences.mockResolvedValue({ email_opt_in: false, push_opt_in: true });
40
+ notificationsApi.getVapidPublicKey.mockResolvedValue('AQID');
41
+ });
42
+
43
+ it('enables push on this device and opts in after saving the subscription', async () => {
44
+ const registration = installPushEnvironment(null);
45
+ const subscription = {
46
+ toJSON: vi.fn(() => ({ endpoint: 'https://fcm.googleapis.com/fcm/send/example' })),
47
+ };
48
+ registration.pushManager.subscribe.mockResolvedValue(subscription);
49
+ vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('granted') });
50
+
51
+ render(<NotificationSettings />);
52
+ fireEvent.click(await screen.findByRole('button', { name: 'NotificationSettings.PUSH_ENABLE' }));
53
+
54
+ await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_opt_in: true }));
55
+ expect(Notification.requestPermission).toHaveBeenCalledOnce();
56
+ expect(registration.pushManager.subscribe).toHaveBeenCalledWith({
57
+ userVisibleOnly: true,
58
+ applicationServerKey: new Uint8Array([1, 2, 3]),
59
+ });
60
+ expect(notificationsApi.savePushSubscription).toHaveBeenCalledWith(
61
+ { endpoint: 'https://fcm.googleapis.com/fcm/send/example' },
62
+ 'Unit Test Browser',
63
+ );
64
+ expect(notificationsApi.savePushSubscription.mock.invocationCallOrder[0])
65
+ .toBeLessThan(notificationsApi.patchNotificationPreferences.mock.invocationCallOrder[0]);
66
+ });
67
+
68
+ it('unsubscribes and removes only this device without changing push_opt_in', async () => {
69
+ const subscription = {
70
+ endpoint: 'https://updates.push.services.mozilla.com/example',
71
+ unsubscribe: vi.fn().mockResolvedValue(true),
72
+ };
73
+ installPushEnvironment(subscription);
74
+
75
+ render(<NotificationSettings />);
76
+ fireEvent.click(await screen.findByRole('button', { name: 'NotificationSettings.PUSH_DISABLE' }));
77
+
78
+ await waitFor(() => expect(subscription.unsubscribe).toHaveBeenCalledOnce());
79
+ expect(notificationsApi.removePushSubscription).toHaveBeenCalledWith({ endpoint: subscription.endpoint });
80
+ expect(notificationsApi.patchNotificationPreferences).not.toHaveBeenCalled();
81
+ });
82
+ });
@@ -0,0 +1,41 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const client = vi.hoisted(() => ({
4
+ delete: vi.fn(),
5
+ get: vi.fn(),
6
+ patch: vi.fn(),
7
+ post: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('../src/auth/apiClient', () => ({ default: client }));
11
+
12
+ import { removePushSubscription, savePushSubscription } from '../src/notifications/api';
13
+
14
+ describe('notifications API', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it('saves a push subscription with its user agent', async () => {
20
+ client.post.mockResolvedValue({ data: { id: 1 } });
21
+ const subscription = { endpoint: 'https://fcm.googleapis.com/fcm/send/example' };
22
+
23
+ await savePushSubscription(subscription, 'Example Browser');
24
+
25
+ expect(client.post).toHaveBeenCalledWith(
26
+ '/api/notifications/preferences/push-subscription/',
27
+ { subscription, ua: 'Example Browser' },
28
+ );
29
+ });
30
+
31
+ it('removes a push subscription by endpoint using an axios delete body', async () => {
32
+ client.delete.mockResolvedValue({ data: null });
33
+
34
+ await removePushSubscription({ endpoint: 'https://updates.push.services.mozilla.com/example' });
35
+
36
+ expect(client.delete).toHaveBeenCalledWith(
37
+ '/api/notifications/preferences/push-subscription/',
38
+ { data: { endpoint: 'https://updates.push.services.mozilla.com/example' } },
39
+ );
40
+ });
41
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { UNIVERSAL_STEP_DESCRIPTORS } from '../src/onboarding/OnboardingProvider';
3
+
4
+ const cookieConsentDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'cookie_consent');
5
+
6
+ describe('cookie_consent descriptor condition', () => {
7
+ it('is active when the user has not accepted the privacy statement, regardless of cookies', () => {
8
+ expect(cookieConsentDescriptor.condition({
9
+ user: { accepted_privacy_statement: false, accepted_convenience_cookies: false },
10
+ })).toBe(true);
11
+ expect(cookieConsentDescriptor.condition({
12
+ user: { accepted_privacy_statement: false, accepted_convenience_cookies: true },
13
+ })).toBe(true);
14
+ });
15
+
16
+ it('is inactive once the privacy statement is accepted, even if cookies were declined', () => {
17
+ expect(cookieConsentDescriptor.condition({
18
+ user: { accepted_privacy_statement: true, accepted_convenience_cookies: false },
19
+ })).toBe(false);
20
+ });
21
+
22
+ it('is inactive with no user', () => {
23
+ expect(cookieConsentDescriptor.condition({ user: null })).toBe(false);
24
+ });
25
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { selectActiveSteps } from '../src/onboarding/stepSelection';
3
+
4
+ const context = {
5
+ user: { first_name: 'Ada', last_name: 'Lovelace', accepted_convenience_cookies: true },
6
+ pushState: { supported: true, subscribed: false },
7
+ };
8
+
9
+ const step = (id, condition, options = {}) => ({
10
+ id,
11
+ condition,
12
+ persistDismissed: options.persistDismissed ?? false,
13
+ });
14
+
15
+ describe('selectActiveSteps', () => {
16
+ it('excludes a config-disabled universal step', () => {
17
+ const result = selectActiveSteps([step('cookie_consent', () => true)], { cookie_consent: false }, context);
18
+ expect(result).toEqual([]);
19
+ });
20
+
21
+ it('excludes a persistently dismissed browser push step', () => {
22
+ const result = selectActiveSteps(
23
+ [step('browser_push', () => true, { persistDismissed: true })],
24
+ { browser_push: true },
25
+ context,
26
+ new Set(['browser_push']),
27
+ );
28
+ expect(result).toEqual([]);
29
+ });
30
+
31
+ it('filters steps whose condition is not met', () => {
32
+ const result = selectActiveSteps(
33
+ [step('complete_name', (ctx) => !ctx.user.first_name)],
34
+ { complete_name: true },
35
+ context,
36
+ );
37
+ expect(result).toEqual([]);
38
+ });
39
+
40
+ it('preserves descriptor order for active universal steps', () => {
41
+ const steps = [
42
+ step('cookie_consent', () => true),
43
+ step('complete_name', () => true),
44
+ step('browser_push', () => true, { persistDismissed: true }),
45
+ ];
46
+ const result = selectActiveSteps(steps, {
47
+ cookie_consent: true,
48
+ complete_name: true,
49
+ browser_push: true,
50
+ }, context);
51
+ expect(result.map((item) => item.id)).toEqual(['cookie_consent', 'complete_name', 'browser_push']);
52
+ });
53
+ });