@micha.bigler/ui-core-micha 2.4.5 → 2.6.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/.github/workflows/publish.yml +4 -0
- package/dist/components/UserListComponent.js +81 -71
- package/dist/i18n/notificationsTranslations.js +17 -0
- package/dist/i18n/onboardingTranslations.js +17 -0
- package/dist/index.js +14 -0
- package/dist/notifications/NotificationSettings.js +148 -0
- package/dist/notifications/api.js +41 -0
- package/dist/notifications/serviceWorker/sw.js +49 -0
- package/dist/onboarding/OnboardingProvider.js +133 -0
- package/dist/onboarding/OnboardingWizard.js +47 -0
- package/dist/onboarding/api.js +10 -0
- package/dist/onboarding/stepSelection.js +18 -0
- package/dist/onboarding/steps/BrowserPushStep.js +49 -0
- package/dist/onboarding/steps/CompleteNameStep.js +37 -0
- package/dist/onboarding/steps/CookieConsentStep.js +31 -0
- package/package.json +7 -4
- package/src/components/UserListComponent.jsx +196 -122
- package/src/i18n/notificationsTranslations.ts +17 -0
- package/src/i18n/onboardingTranslations.ts +17 -0
- package/src/index.js +22 -0
- package/src/notifications/NotificationSettings.jsx +189 -0
- package/src/notifications/api.js +49 -0
- package/src/notifications/serviceWorker/sw.js +50 -0
- package/src/onboarding/OnboardingProvider.jsx +153 -0
- package/src/onboarding/OnboardingWizard.jsx +66 -0
- package/src/onboarding/api.js +13 -0
- package/src/onboarding/stepSelection.js +16 -0
- package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
- package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
- package/src/onboarding/steps/CookieConsentStep.jsx +39 -0
- package/tests/NotificationSettings.test.jsx +82 -0
- package/tests/notificationsApi.test.js +41 -0
- package/tests/stepSelection.test.js +53 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Alert from '@mui/material/Alert';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
7
|
+
import Typography from '@mui/material/Typography';
|
|
8
|
+
import CookieIcon from '@mui/icons-material/Cookie';
|
|
9
|
+
import { updateUserProfile } from '../../auth/authApi';
|
|
10
|
+
|
|
11
|
+
export function CookieConsentStep({ onComplete }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const [loading, setLoading] = useState(false);
|
|
14
|
+
const [error, setError] = useState('');
|
|
15
|
+
|
|
16
|
+
const accept = async () => {
|
|
17
|
+
setLoading(true);
|
|
18
|
+
setError('');
|
|
19
|
+
try {
|
|
20
|
+
await updateUserProfile({ accepted_convenience_cookies: true });
|
|
21
|
+
onComplete();
|
|
22
|
+
} catch {
|
|
23
|
+
setError(t('Onboarding.COOKIE_CONSENT_ERROR'));
|
|
24
|
+
} finally {
|
|
25
|
+
setLoading(false);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
31
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><CookieIcon color="primary" /><Typography variant="h6">{t('Onboarding.COOKIE_CONSENT_TITLE')}</Typography></Box>
|
|
32
|
+
<Typography variant="body2" color="text.secondary">{t('Onboarding.COOKIE_CONSENT_BODY')}</Typography>
|
|
33
|
+
{error && <Alert severity="error" onClose={() => setError('')}>{error}</Alert>}
|
|
34
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}><Button variant="contained" onClick={accept} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('Onboarding.COOKIE_CONSENT_ACCEPT')}</Button></Box>
|
|
35
|
+
</Box>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default CookieConsentStep;
|
|
@@ -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,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
|
+
});
|