Skip to content

Commit 3caa2a2

Browse files
authored
feat: implement send email on release notes publish (#102)
* feat: implement send email for release notes publish * fix: rename unsubscribe function and update related tests * fix: Add email notification features for release notes * fix: add proptype for sendemail * fix: simplify unsubscribe functionality by removing token parameter
1 parent dfd0ae1 commit 3caa2a2

16 files changed

Lines changed: 477 additions & 35 deletions

src/index.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import initializeStore from './store';
2727
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
2828
import ReleaseNotes from './release-notes/ReleaseNotes';
29+
import ReleaseNoteUnsubscribe from './release-notes/unsubscribe/ReleaseNoteUnsubscribe';
2930
import Head from './head/Head';
3031
import { StudioHome } from './studio-home';
3132
import CourseRerun from './course-rerun';
@@ -97,6 +98,7 @@ const App = () => {
9798
{getConfig().ENABLE_RELEASE_NOTES === 'true' && (
9899
<Route path="/release-notes" element={<ReleaseNotes />} />
99100
)}
101+
<Route path="/release-notes/unsubscribe" element={<ReleaseNoteUnsubscribe />} />
100102
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (
101103
<Route path="/accessibility" element={<AccessibilityPage />} />
102104
)}

src/release-notes/ReleaseNotes.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const ReleaseNotes = () => {
3636
requestType,
3737
notes,
3838
hasAccess,
39+
canSendReleaseNoteEmails,
3940
notesInitialValues,
4041
isFormOpen,
4142
isDeleteModalOpen,
@@ -277,6 +278,7 @@ const ReleaseNotes = () => {
277278
initialValues={notesInitialValues}
278279
close={closeForm}
279280
onSubmit={handleUpdatesSubmit}
281+
canSendReleaseNoteEmails={canSendReleaseNoteEmails}
280282
savingStatuses={savingStatuses}
281283
isDirtyCheckRef={isDirtyCheckRef}
282284
showUnsavedModalRef={showUnsavedModalRef}

src/release-notes/ReleaseNotes.test.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const mockUseReleaseNotes = {
2222
requestType: null,
2323
notes: [],
2424
hasAccess: false,
25+
canSendReleaseNoteEmails: false,
2526
notesInitialValues: {},
2627
isFormOpen: false,
2728
isDeleteModalOpen: false,

src/release-notes/data/api.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
55

66
const getReleaseNotesApiUrl = () => new URL('/api/release_notes/v1/posts/', getApiBaseUrl()).href;
77
const getReleaseNoteApiUrl = (id) => new URL(`/api/release_notes/v1/posts/${id}/`, getApiBaseUrl()).href;
8+
const getUnsubscribeApiUrl = () => new URL('/api/release_notes/v1/email/unsubscribe/', getApiBaseUrl()).href;
89

910
export async function getReleaseNotes() {
1011
const { data } = await getAuthenticatedHttpClient().get(getReleaseNotesApiUrl());
@@ -25,3 +26,8 @@ export async function deleteReleaseNote(id) {
2526
const { data } = await getAuthenticatedHttpClient().delete(getReleaseNoteApiUrl(id));
2627
return camelCaseObject(data);
2728
}
29+
30+
export async function unsubscribeFromReleaseNoteEmails() {
31+
const { data } = await getAuthenticatedHttpClient().get(getUnsubscribeApiUrl());
32+
return camelCaseObject(data);
33+
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import MockAdapter from 'axios-mock-adapter';
44

55
import { initializeMocks } from '../../testUtils';
66
import {
7-
getReleaseNotes, createReleaseNote, editReleaseNote, deleteReleaseNote,
7+
getReleaseNotes,
8+
createReleaseNote,
9+
editReleaseNote,
10+
deleteReleaseNote,
11+
unsubscribeFromReleaseNoteEmails,
812
} from './api';
913

1014
describe('release-notes api', () => {
@@ -53,4 +57,12 @@ describe('release-notes api', () => {
5357
mock.onGet(url).reply(500, {});
5458
await expect(getReleaseNotes()).rejects.toBeTruthy();
5559
});
60+
61+
test('unsubscribeFromReleaseNoteEmails issues authenticated GET', async () => {
62+
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();
65+
expect(res.message).toBe('unsubscribed');
66+
expect(mock.history.get[0].url).toBe(expectedUrl);
67+
});
5668
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const getReleaseNotes = (state) => state.releaseNotes.releaseNotes;
22
export const getHasAccess = (state) => state.releaseNotes.hasAccess;
3+
export const getCanSendReleaseNoteEmails = (state) => state.releaseNotes.canSendReleaseNoteEmails;
34
export const getSavingStatuses = (state) => state.releaseNotes.savingStatuses;
45
export const getLoadingStatuses = (state) => state.releaseNotes.loadingStatuses;
56
export const getErrors = (state) => state.releaseNotes.errors;

src/release-notes/data/slice.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { sortBy } from 'lodash';
55
const initialState = {
66
releaseNotes: [],
77
hasAccess: false,
8+
canSendReleaseNoteEmails: false,
89
savingStatuses: {
910
createReleaseNoteQuery: '',
1011
editReleaseNoteQuery: '',
@@ -28,6 +29,7 @@ const slice = createSlice({
2829
fetchReleaseNotesSuccess: (state, { payload }) => {
2930
state.releaseNotes = payload.notes || payload;
3031
state.hasAccess = payload.hasAccess || false;
32+
state.canSendReleaseNoteEmails = payload.canSendReleaseNoteEmails || false;
3133
},
3234
createReleaseNote: (state, { payload }) => {
3335
state.releaseNotes = [payload, ...state.releaseNotes];

src/release-notes/data/thunk.js

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,34 @@ import {
1616
updateSavingStatuses,
1717
} from './slice';
1818

19+
function normalizeReleaseNote(note) {
20+
return {
21+
id: note.id,
22+
title: note.title,
23+
description: note.rawHtmlContent,
24+
published_at: note.publishedAt,
25+
created_by: note.createdBy,
26+
sendEmail: Boolean(note.sendEmailOnPublish),
27+
};
28+
}
29+
1930
export function fetchReleaseNotesQuery() {
2031
return async (dispatch) => {
2132
try {
2233
dispatch(updateLoadingStatuses({ fetchReleaseNotesQuery: RequestStatus.IN_PROGRESS }));
2334
const response = await getReleaseNotes();
2435
const notesList = Array.isArray(response) ? response : (response.results || []);
2536
const hasAccess = response.hasAccess || false;
26-
const normalized = (notesList || []).map((n) => ({
27-
id: n.id,
28-
title: n.title,
29-
description: n.rawHtmlContent,
30-
published_at: n.publishedAt,
31-
created_by: n.createdBy,
32-
}))
37+
const canSendReleaseNoteEmails = response.canSendReleaseNoteEmails || false;
38+
const normalized = (notesList || []).map(normalizeReleaseNote)
3339
.sort((a, b) => {
3440
const ta = a.published_at ? Date.parse(a.published_at) : -Infinity;
3541
const tb = b.published_at ? Date.parse(b.published_at) : -Infinity;
3642
return tb - ta;
3743
});
38-
dispatch(fetchReleaseNotesSuccess({ notes: normalized, hasAccess }));
44+
dispatch(fetchReleaseNotesSuccess({
45+
notes: normalized, hasAccess, canSendReleaseNoteEmails,
46+
}));
3947
dispatch(updateLoadingStatuses({
4048
status: { fetchReleaseNotesQuery: RequestStatus.SUCCESSFUL },
4149
error: { loadingNotes: false },
@@ -57,20 +65,15 @@ export function createReleaseNoteQuery(data) {
5765
error: {},
5866
}));
5967
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
68+
const { sendEmail, ...noteData } = data;
6069
const payload = {
61-
...data,
62-
raw_html_content: data.description,
70+
...noteData,
71+
raw_html_content: noteData.description,
72+
send_email_on_publish: sendEmail || false,
6373
};
6474
delete payload.description;
6575
const note = await createReleaseNote(payload);
66-
const normalized = {
67-
id: note.id,
68-
title: note.title,
69-
description: note.rawHtmlContent,
70-
published_at: note.publishedAt,
71-
created_by: note.createdBy,
72-
};
73-
dispatch(createReleaseNoteAction(normalized));
76+
dispatch(createReleaseNoteAction(normalizeReleaseNote(note)));
7477
dispatch(hideProcessingNotification());
7578
dispatch(updateSavingStatuses({
7679
status: { createReleaseNoteQuery: RequestStatus.SUCCESSFUL },
@@ -96,20 +99,15 @@ export function editReleaseNoteQuery(data) {
9699
error: {},
97100
}));
98101
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
102+
const { sendEmail, ...noteData } = data;
99103
const payload = {
100-
...data,
101-
raw_html_content: data.description,
104+
...noteData,
105+
raw_html_content: noteData.description,
106+
send_email_on_publish: sendEmail || false,
102107
};
103108
delete payload.description;
104109
const note = await editReleaseNote(payload);
105-
const normalized = {
106-
id: note.id,
107-
title: note.title,
108-
description: note.rawHtmlContent,
109-
published_at: note.publishedAt,
110-
created_by: note.createdBy,
111-
};
112-
dispatch(editReleaseNoteAction(normalized));
110+
dispatch(editReleaseNoteAction(normalizeReleaseNote(note)));
113111
dispatch(hideProcessingNotification());
114112
dispatch(updateSavingStatuses({
115113
status: { editReleaseNoteQuery: RequestStatus.SUCCESSFUL },

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

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,58 @@ describe('release-notes thunks', () => {
4343
expect(success.payload.notes[0].id).toBe(2);
4444
expect(success.payload.notes[0]).toEqual(expect.objectContaining({ description: '<p>b</p>', published_at: expect.any(String), created_by: 'b@x' }));
4545
expect(success.payload.hasAccess).toBe(false);
46+
expect(success.payload.canSendReleaseNoteEmails).toBe(false);
4647
const last = actions[actions.length - 1];
4748
expect(last.type).toBe(updateLoadingStatuses.type);
4849
expect(last.payload.status.fetchReleaseNotesQuery).toBe(RequestStatus.SUCCESSFUL);
4950
});
5051

52+
test('fetchReleaseNotesQuery maps sendEmailOnPublish to sendEmail', async () => {
53+
jest.spyOn(api, 'getReleaseNotes').mockResolvedValue([
54+
{
55+
id: 1,
56+
title: 'a',
57+
rawHtmlContent: '<p>a</p>',
58+
publishedAt: '2025-01-01T00:00:00Z',
59+
createdBy: 'a@x',
60+
sendEmailOnPublish: true,
61+
},
62+
{
63+
id: 2,
64+
title: 'b',
65+
rawHtmlContent: '<p>b</p>',
66+
publishedAt: '2025-01-02T00:00:00Z',
67+
createdBy: 'b@x',
68+
sendEmailOnPublish: false,
69+
},
70+
]);
71+
const { actions, dispatch } = collectActions();
72+
73+
await fetchReleaseNotesQuery()(dispatch);
74+
75+
const success = actions.find(a => a.type === fetchReleaseNotesSuccess.type);
76+
expect(success.payload.notes.find(n => n.id === 1).sendEmail).toBe(true);
77+
expect(success.payload.notes.find(n => n.id === 2).sendEmail).toBe(false);
78+
});
79+
80+
test('fetchReleaseNotesQuery reads hasAccess and canSendReleaseNoteEmails from paginated response', async () => {
81+
jest.spyOn(api, 'getReleaseNotes').mockResolvedValue({
82+
results: [{
83+
id: 1, title: 'a', rawHtmlContent: '<p>a</p>', publishedAt: '2025-01-01T00:00:00Z', createdBy: 'a@x',
84+
}],
85+
hasAccess: true,
86+
canSendReleaseNoteEmails: true,
87+
});
88+
const { actions, dispatch } = collectActions();
89+
90+
await fetchReleaseNotesQuery()(dispatch);
91+
92+
const success = actions.find(a => a.type === fetchReleaseNotesSuccess.type);
93+
expect(success.payload.hasAccess).toBe(true);
94+
expect(success.payload.canSendReleaseNoteEmails).toBe(true);
95+
expect(success.payload.notes).toHaveLength(1);
96+
});
97+
5198
test('fetchReleaseNotesQuery failure sets FAILED', async () => {
5299
jest.spyOn(api, 'getReleaseNotes').mockRejectedValue(new Error('boom'));
53100
const { actions, dispatch } = collectActions();
@@ -60,11 +107,20 @@ describe('release-notes thunks', () => {
60107

61108
test('createReleaseNoteQuery success creates note and sets SUCCESSFUL', async () => {
62109
jest.spyOn(api, 'createReleaseNote').mockResolvedValue({
63-
id: 9, title: 't', rawHtmlContent: '<p>x</p>', publishedAt: '2025-01-01T00:00:00Z', createdBy: 'u',
110+
id: 9,
111+
title: 't',
112+
rawHtmlContent: '<p>x</p>',
113+
publishedAt: '2025-01-01T00:00:00Z',
114+
createdBy: 'u',
115+
sendEmailOnPublish: true,
64116
});
65117
const { actions, dispatch } = collectActions();
66-
await createReleaseNoteQuery({ id: 9, title: 't', description: '<p>x</p>' })(dispatch);
67-
expect(actions.find(a => a.type === createReleaseNoteAction.type)).toBeTruthy();
118+
await createReleaseNoteQuery({
119+
id: 9, title: 't', description: '<p>x</p>', sendEmail: true,
120+
})(dispatch);
121+
const created = actions.find(a => a.type === createReleaseNoteAction.type);
122+
expect(created).toBeTruthy();
123+
expect(created.payload.sendEmail).toBe(true);
68124
const last = actions[actions.length - 1];
69125
expect(last.type).toBe(updateSavingStatuses.type);
70126
expect(last.payload.status.createReleaseNoteQuery).toBe(RequestStatus.SUCCESSFUL);
@@ -92,11 +148,20 @@ describe('release-notes thunks', () => {
92148

93149
test('editReleaseNoteQuery success edits note and sets SUCCESSFUL', async () => {
94150
jest.spyOn(api, 'editReleaseNote').mockResolvedValue({
95-
id: 5, title: 't2', rawHtmlContent: '<p>y</p>', publishedAt: '2025-01-03T00:00:00Z', createdBy: 'v',
151+
id: 5,
152+
title: 't2',
153+
rawHtmlContent: '<p>y</p>',
154+
publishedAt: '2025-01-03T00:00:00Z',
155+
createdBy: 'v',
156+
sendEmailOnPublish: true,
96157
});
97158
const { actions, dispatch } = collectActions();
98-
await editReleaseNoteQuery({ id: 5, title: 't2', description: '<p>y</p>' })(dispatch);
99-
expect(actions.find(a => a.type === editReleaseNoteAction.type)).toBeTruthy();
159+
await editReleaseNoteQuery({
160+
id: 5, title: 't2', description: '<p>y</p>', sendEmail: true,
161+
})(dispatch);
162+
const edited = actions.find(a => a.type === editReleaseNoteAction.type);
163+
expect(edited).toBeTruthy();
164+
expect(edited.payload.sendEmail).toBe(true);
100165
const last = actions[actions.length - 1];
101166
expect(last.type).toBe(updateSavingStatuses.type);
102167
expect(last.payload.status.editReleaseNoteQuery).toBe(RequestStatus.SUCCESSFUL);
@@ -157,4 +222,38 @@ describe('release-notes thunks', () => {
157222
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ raw_html_content: '<p>y</p>' }));
158223
expect(spy).toHaveBeenCalledWith(expect.not.objectContaining({ description: expect.anything() }));
159224
});
225+
226+
test('createReleaseNoteQuery maps sendEmail to send_email_on_publish (true)', async () => {
227+
const spy = jest.spyOn(api, 'createReleaseNote').mockResolvedValue({ id: 1, title: 't', rawHtmlContent: '<p>x</p>' });
228+
const { dispatch } = collectActions();
229+
await createReleaseNoteQuery({
230+
id: 1, title: 't', description: '<p>x</p>', sendEmail: true,
231+
})(dispatch);
232+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ send_email_on_publish: true }));
233+
expect(spy).toHaveBeenCalledWith(expect.not.objectContaining({ sendEmail: expect.anything() }));
234+
});
235+
236+
test('createReleaseNoteQuery defaults send_email_on_publish to false when sendEmail absent', async () => {
237+
const spy = jest.spyOn(api, 'createReleaseNote').mockResolvedValue({ id: 1, title: 't', rawHtmlContent: '<p>x</p>' });
238+
const { dispatch } = collectActions();
239+
await createReleaseNoteQuery({ id: 1, title: 't', description: '<p>x</p>' })(dispatch);
240+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ send_email_on_publish: false }));
241+
});
242+
243+
test('editReleaseNoteQuery maps sendEmail to send_email_on_publish (true)', async () => {
244+
const spy = jest.spyOn(api, 'editReleaseNote').mockResolvedValue({ id: 2, title: 't2', rawHtmlContent: '<p>y</p>' });
245+
const { dispatch } = collectActions();
246+
await editReleaseNoteQuery({
247+
id: 2, title: 't2', description: '<p>y</p>', sendEmail: true,
248+
})(dispatch);
249+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ send_email_on_publish: true }));
250+
expect(spy).toHaveBeenCalledWith(expect.not.objectContaining({ sendEmail: expect.anything() }));
251+
});
252+
253+
test('editReleaseNoteQuery defaults send_email_on_publish to false when sendEmail absent', async () => {
254+
const spy = jest.spyOn(api, 'editReleaseNote').mockResolvedValue({ id: 2, title: 't2', rawHtmlContent: '<p>y</p>' });
255+
const { dispatch } = collectActions();
256+
await editReleaseNoteQuery({ id: 2, title: 't2', description: '<p>y</p>' })(dispatch);
257+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ send_email_on_publish: false }));
258+
});
160259
});

src/release-notes/hooks.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import {
1414
getReleaseNotes as selectReleaseNotes,
1515
getHasAccess,
16+
getCanSendReleaseNoteEmails,
1617
getLoadingStatuses,
1718
getSavingStatuses,
1819
getErrors,
@@ -31,6 +32,7 @@ const useReleaseNotes = () => {
3132

3233
const notes = useSelector(selectReleaseNotes) || [];
3334
const hasAccess = useSelector(getHasAccess);
35+
const canSendReleaseNoteEmails = useSelector(getCanSendReleaseNoteEmails);
3436
const loadingStatuses = useSelector(getLoadingStatuses);
3537
const savingStatuses = useSelector(getSavingStatuses);
3638
const errors = useSelector(getErrors);
@@ -105,6 +107,7 @@ const useReleaseNotes = () => {
105107
requestType,
106108
notes,
107109
hasAccess,
110+
canSendReleaseNoteEmails,
108111
notesInitialValues,
109112
isMainFormOpen: isFormOpen && requestType !== REQUEST_TYPES.edit_update,
110113
isInnerFormOpen: (id) => (

0 commit comments

Comments
 (0)