Skip to content

Commit 45a6f67

Browse files
authored
feat(release-notes): enhance unsubscribe functionality and user experience (#105)
1 parent 0580fdf commit 45a6f67

6 files changed

Lines changed: 77 additions & 18 deletions

File tree

src/index.jsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import {
22
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig, getPath,
33
} from '@edx/frontend-platform';
4+
import {
5+
ensureAuthenticatedUser, fetchAuthenticatedUser, getAuthenticatedUser, hydrateAuthenticatedUser,
6+
} from '@edx/frontend-platform/auth';
47
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
58
import React, { StrictMode, useEffect } from 'react';
69
import { createRoot } from 'react-dom/client';
@@ -48,6 +51,11 @@ const queryClient = new QueryClient({
4851
},
4952
});
5053

54+
const isReleaseNoteUnsubscribePath = () => {
55+
const normalizedPath = global.location.pathname.replace(/\/$/, '');
56+
return normalizedPath.endsWith('/release-notes/unsubscribe');
57+
};
58+
5159
const App = () => {
5260
useEffect(() => {
5361
if (process.env.HOTJAR_APP_ID) {
@@ -189,6 +197,19 @@ initialize({
189197
ENABLE_RELEASE_NOTES_BANNER: process.env.ENABLE_RELEASE_NOTES_BANNER || 'false',
190198
}, 'CourseAuthoringConfig');
191199
},
200+
auth: async (requireUser, hydrateUser) => {
201+
if (isReleaseNoteUnsubscribePath()) {
202+
await fetchAuthenticatedUser();
203+
} else if (requireUser) {
204+
await ensureAuthenticatedUser(globalThis.location.href);
205+
} else {
206+
await fetchAuthenticatedUser();
207+
}
208+
209+
if (hydrateUser && getAuthenticatedUser() !== null) {
210+
hydrateAuthenticatedUser();
211+
}
212+
},
192213
},
193214
messages,
194215
requireAuthenticatedUser: true,

src/release-notes/data/api.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
2-
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
2+
import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth';
33

44
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
55

@@ -27,7 +27,7 @@ export async function deleteReleaseNote(id) {
2727
return camelCaseObject(data);
2828
}
2929

30-
export async function unsubscribeFromReleaseNoteEmails() {
31-
const { data } = await getAuthenticatedHttpClient().get(getUnsubscribeApiUrl());
30+
export async function unsubscribeFromReleaseNoteEmails(token) {
31+
const { data } = await getHttpClient().post(getUnsubscribeApiUrl(), { token });
3232
return camelCaseObject(data);
3333
}

src/release-notes/data/api.test.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getConfig } from '@edx/frontend-platform';
2-
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
2+
import { getAuthenticatedHttpClient, getHttpClient } from '@edx/frontend-platform/auth';
33
import MockAdapter from 'axios-mock-adapter';
44

55
import { initializeMocks } from '../../testUtils';
@@ -58,11 +58,13 @@ describe('release-notes api', () => {
5858
await expect(getReleaseNotes()).rejects.toBeTruthy();
5959
});
6060

61-
test('unsubscribeFromReleaseNoteEmails issues authenticated GET', async () => {
61+
test('unsubscribeFromReleaseNoteEmails posts token without authentication', async () => {
6262
const expectedUrl = new URL('/api/release_notes/v1/email/unsubscribe/', getConfig().STUDIO_BASE_URL).href;
63-
mock.onGet(expectedUrl).reply(200, { message: 'unsubscribed' });
64-
const res = await unsubscribeFromReleaseNoteEmails();
63+
const unauthenticatedMock = new MockAdapter(getHttpClient());
64+
unauthenticatedMock.onPost(expectedUrl).reply(200, { message: 'unsubscribed' });
65+
const res = await unsubscribeFromReleaseNoteEmails('signed-token');
6566
expect(res.message).toBe('unsubscribed');
66-
expect(mock.history.get[0].url).toBe(expectedUrl);
67+
expect(unauthenticatedMock.history.post[0].url).toBe(expectedUrl);
68+
expect(JSON.parse(unauthenticatedMock.history.post[0].data)).toEqual({ token: 'signed-token' });
6769
});
6870
});

src/release-notes/unsubscribe/ReleaseNoteUnsubscribe.jsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1-
import React, { useState } from 'react';
1+
import React, { useMemo, useState } from 'react';
22
import {
33
Alert, Button, Card, Container, Spinner,
44
} from '@openedx/paragon';
55
import { Info, CheckCircle, Email } from '@openedx/paragon/icons';
66
import { useIntl } from '@edx/frontend-platform/i18n';
7+
import { useSearchParams } from 'react-router-dom';
78
import { unsubscribeFromReleaseNoteEmails } from '../data/api';
89
import messages from './messages';
910

1011
const ReleaseNoteUnsubscribe = () => {
1112
const intl = useIntl();
12-
const [status, setStatus] = useState('idle'); // idle, loading, success, error
13+
const [searchParams] = useSearchParams();
14+
const token = useMemo(() => searchParams.get('token'), [searchParams]);
15+
const [status, setStatus] = useState(token ? 'idle' : 'invalid');
1316

1417
const handleUnsubscribe = () => {
18+
if (!token) {
19+
setStatus('invalid');
20+
return;
21+
}
22+
1523
setStatus('loading');
1624

17-
unsubscribeFromReleaseNoteEmails()
25+
unsubscribeFromReleaseNoteEmails(token)
1826
.then(() => {
1927
setStatus('success');
2028
})
@@ -25,6 +33,12 @@ const ReleaseNoteUnsubscribe = () => {
2533

2634
return (
2735
<Container size="sm" className="d-flex align-items-center justify-content-center py-5">
36+
{status === 'invalid' && (
37+
<Alert variant="danger" icon={Info}>
38+
<Alert.Heading>{intl.formatMessage(messages.unsubscribeInvalidTitle)}</Alert.Heading>
39+
<p className="mb-0">{intl.formatMessage(messages.unsubscribeInvalid)}</p>
40+
</Alert>
41+
)}
2842
{status === 'idle' && (
2943
<Card className="text-center shadow-sm" style={{ maxWidth: '480px' }}>
3044
<Card.Section className="p-4">

src/release-notes/unsubscribe/ReleaseNoteUnsubscribe.test.jsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,49 @@ import ReleaseNoteUnsubscribe from './ReleaseNoteUnsubscribe';
77
import messages from './messages';
88
import * as api from '../data/api';
99

10+
const renderUnsubscribePage = (search = '') => render(
11+
<ReleaseNoteUnsubscribe />,
12+
{
13+
path: '/release-notes/unsubscribe',
14+
routerProps: { initialEntries: [`/release-notes/unsubscribe${search}`] },
15+
},
16+
);
17+
1018
describe('ReleaseNoteUnsubscribe', () => {
1119
beforeEach(() => {
1220
initializeMocks();
1321
});
1422

15-
test('renders the idle confirmation state', () => {
16-
render(<ReleaseNoteUnsubscribe />);
23+
test('renders invalid state when token is missing', () => {
24+
renderUnsubscribePage();
25+
expect(screen.getByText(messages.unsubscribeInvalidTitle.defaultMessage)).toBeInTheDocument();
26+
expect(screen.getByText(messages.unsubscribeInvalid.defaultMessage)).toBeInTheDocument();
27+
});
28+
29+
test('renders the idle confirmation state when token is present', () => {
30+
renderUnsubscribePage('?token=signed-token');
1731
expect(screen.getByText(messages.unsubscribeTitle.defaultMessage)).toBeInTheDocument();
1832
expect(screen.getByText(messages.unsubscribeConfirmation.defaultMessage)).toBeInTheDocument();
1933
expect(
2034
screen.getByRole('button', { name: messages.unsubscribeButton.defaultMessage }),
2135
).toBeInTheDocument();
2236
});
2337

24-
test('calls unsubscribeFromReleaseNoteEmails and shows success state on success', async () => {
38+
test('calls unsubscribeFromReleaseNoteEmails with token and shows success state on success', async () => {
2539
const spy = jest.spyOn(api, 'unsubscribeFromReleaseNoteEmails').mockResolvedValue({ message: 'ok' });
26-
render(<ReleaseNoteUnsubscribe />);
40+
renderUnsubscribePage('?token=signed-token');
2741

2842
fireEvent.click(screen.getByRole('button', { name: messages.unsubscribeButton.defaultMessage }));
2943

3044
await waitFor(() => {
3145
expect(screen.getByText(messages.unsubscribeSuccessTitle.defaultMessage)).toBeInTheDocument();
3246
});
33-
expect(spy).toHaveBeenCalledWith();
47+
expect(spy).toHaveBeenCalledWith('signed-token');
3448
});
3549

3650
test('shows error state and allows retry on failure', async () => {
3751
const spy = jest.spyOn(api, 'unsubscribeFromReleaseNoteEmails').mockRejectedValue(new Error('boom'));
38-
render(<ReleaseNoteUnsubscribe />);
52+
renderUnsubscribePage('?token=signed-token');
3953

4054
fireEvent.click(screen.getByRole('button', { name: messages.unsubscribeButton.defaultMessage }));
4155

src/release-notes/unsubscribe/messages.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,15 @@ const messages = defineMessages({
3535
},
3636
unsubscribeError: {
3737
id: 'release-notes.unsubscribe.error',
38-
defaultMessage: 'We were unable to process your unsubscribe request. Please sign in with the account that received the email and try again.',
38+
defaultMessage: 'We were unable to process your unsubscribe request. Please use the unsubscribe link from your Release Notes email and try again.',
39+
},
40+
unsubscribeInvalidTitle: {
41+
id: 'release-notes.unsubscribe.invalid.title',
42+
defaultMessage: 'Invalid Unsubscribe Link',
43+
},
44+
unsubscribeInvalid: {
45+
id: 'release-notes.unsubscribe.invalid',
46+
defaultMessage: 'This unsubscribe link is invalid or incomplete. Please open the unsubscribe link from your Release Notes email.',
3947
},
4048
unsubscribeRetry: {
4149
id: 'release-notes.unsubscribe.retry',

0 commit comments

Comments
 (0)