diff --git a/src/course-home/data/apiHooks.test.tsx b/src/course-home/data/apiHooks.test.tsx
new file mode 100644
index 0000000000..1ae3aa186b
--- /dev/null
+++ b/src/course-home/data/apiHooks.test.tsx
@@ -0,0 +1,119 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import MockAdapter from 'axios-mock-adapter';
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import { initializeMockApp } from '../../setupTest';
+import { ToastProvider, useToast } from '../../generic/ToastContext';
+import { useResetDeadlines, usePostEvent } from './apiHooks';
+
+const { loggingService } = initializeMockApp();
+
+const buildWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+ return { wrapper };
+};
+
+describe('course-home apiHooks', () => {
+ let axiosMock: MockAdapter;
+
+ beforeEach(() => {
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ loggingService.logError.mockReset();
+ });
+
+ describe('useResetDeadlines', () => {
+ const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
+
+ it('POSTs and surfaces the server response as an open toast', async () => {
+ axiosMock.onPost(resetUrl).reply(201, {
+ header: 'test-toast-header', link: 'test-toast-link', link_text: 'test-toast-body',
+ });
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => ({ reset: useResetDeadlines(), toast: useToast() }), { wrapper });
+
+ await act(async () => { await result.current.reset.mutateAsync({ courseId: 'course-1', model: 'dates' }); });
+
+ expect(axiosMock.history.post[0].data).toEqual(
+ '{"course_key":"course-1","research_event_data":{"location":"dates-tab"}}',
+ );
+ expect(result.current.toast.toastContent).toEqual({
+ message: 'test-toast-header',
+ action: { label: 'test-toast-body', href: 'test-toast-link' },
+ });
+ expect(result.current.toast.isToastOpen).toBe(true);
+ });
+
+ it('omits the toast action when the response has no link text', async () => {
+ axiosMock.onPost(resetUrl).reply(200, { header: 'done', link: null, link_text: '' });
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => ({ reset: useResetDeadlines(), toast: useToast() }), { wrapper });
+
+ await act(async () => { await result.current.reset.mutateAsync({ courseId: 'course-1', model: 'outline' }); });
+
+ expect(result.current.toast.toastContent).toEqual({ message: 'done', action: undefined });
+ expect(result.current.toast.isToastOpen).toBe(true);
+ });
+
+ it('logs the error when the POST fails', async () => {
+ axiosMock.onPost(resetUrl).reply(500);
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useResetDeadlines(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync({ courseId: 'course-1', model: 'dates' }).catch(() => {});
+ });
+
+ await waitFor(() => expect(loggingService.logError).toHaveBeenCalled());
+ });
+ });
+
+ describe('usePostEvent', () => {
+ const postUrl = 'http://example.com/post-event';
+
+ it('POSTs to the event url and surfaces the response as an open toast', async () => {
+ axiosMock.onPost(postUrl).reply(200, {
+ header: 'post-header', link: 'post-link', link_text: 'post-body',
+ });
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => ({ post: usePostEvent(), toast: useToast() }), { wrapper });
+
+ await act(async () => {
+ await result.current.post.mutateAsync({
+ postData: { url: postUrl, bodyParams: { courseId: 'course-1' } },
+ researchEventData: { location: 'unit' },
+ });
+ });
+
+ expect(axiosMock.history.post[0].url).toEqual(postUrl);
+ expect(result.current.toast.toastContent).toEqual({
+ message: 'post-header',
+ action: { label: 'post-body', href: 'post-link' },
+ });
+ expect(result.current.toast.isToastOpen).toBe(true);
+ });
+
+ it('logs the error when the POST fails', async () => {
+ axiosMock.onPost(postUrl).reply(500);
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => usePostEvent(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync({
+ postData: { url: postUrl, bodyParams: { courseId: 'course-1' } },
+ researchEventData: { location: 'unit' },
+ }).catch(() => {});
+ });
+
+ await waitFor(() => expect(loggingService.logError).toHaveBeenCalled());
+ });
+ });
+});
diff --git a/src/course-home/data/apiHooks.ts b/src/course-home/data/apiHooks.ts
new file mode 100644
index 0000000000..22a9162f2e
--- /dev/null
+++ b/src/course-home/data/apiHooks.ts
@@ -0,0 +1,47 @@
+import { logError } from '@edx/frontend-platform/logging';
+import { useMutation } from '@tanstack/react-query';
+
+import { useToast, ToastContent } from '@src/generic/ToastContext';
+import { executePostFromPostEvent, postCourseDeadlines } from './api';
+
+interface CallToActionResponse {
+ header: string;
+ link: string;
+ link_text: string;
+}
+
+interface PostData {
+ url: string;
+ bodyParams: { courseId: string };
+}
+
+const toastFrom = ({ header, link, link_text: linkText }: CallToActionResponse): ToastContent => ({
+ message: header,
+ action: linkText ? { label: linkText, href: link } : undefined,
+});
+
+export const useResetDeadlines = () => {
+ const { setToastContent, openToast } = useToast();
+ return useMutation({
+ mutationFn: ({ courseId, model }: { courseId: string; model: string }) => postCourseDeadlines(courseId, model),
+ onSuccess: ({ data }) => {
+ setToastContent(toastFrom(data));
+ openToast();
+ },
+ onError: (error) => logError(error),
+ });
+};
+
+export const usePostEvent = () => {
+ const { setToastContent, openToast } = useToast();
+ return useMutation({
+ mutationFn: ({ postData, researchEventData }: { postData: PostData; researchEventData: unknown }) => (
+ executePostFromPostEvent(postData, researchEventData)
+ ),
+ onSuccess: ({ data }) => {
+ setToastContent(toastFrom(data));
+ openToast();
+ },
+ onError: (error) => logError(error),
+ });
+};
diff --git a/src/course-home/data/index.js b/src/course-home/data/index.js
index c315d84eb2..af1529f0dc 100644
--- a/src/course-home/data/index.js
+++ b/src/course-home/data/index.js
@@ -2,7 +2,6 @@ export {
fetchDatesTab,
fetchOutlineTab,
fetchProgressTab,
- resetDeadlines,
deprecatedSaveCourseGoal,
saveWeeklyLearningGoal,
} from './thunks';
diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js
index c053e7694e..7de295731a 100644
--- a/src/course-home/data/redux.test.js
+++ b/src/course-home/data/redux.test.js
@@ -237,25 +237,6 @@ describe('Data layer integration tests', () => {
});
});
- describe('Test resetDeadlines', () => {
- it('Should reset course deadlines', async () => {
- const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
- const model = 'dates';
- axiosMock.onPost(resetUrl).reply(201, {});
-
- const getTabDataMock = jest.fn(() => ({
- type: 'MOCK_ACTION',
- }));
-
- await executeThunk(thunks.resetDeadlines(courseId, model, getTabDataMock), store.dispatch);
-
- expect(axiosMock.history.post[0].url).toEqual(resetUrl);
- expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}","research_event_data":{"location":"dates-tab"}}`);
-
- expect(getTabDataMock).toHaveBeenCalledWith(courseId);
- });
- });
-
describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => {
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
diff --git a/src/course-home/data/slice.js b/src/course-home/data/slice.js
index 6267ebaf7b..9a5972448c 100644
--- a/src/course-home/data/slice.js
+++ b/src/course-home/data/slice.js
@@ -14,9 +14,6 @@ const slice = createSlice({
courseStatus: 'loading',
courseId: null,
proctoringPanelStatus: 'loading',
- toastBodyText: null,
- toastBodyLink: null,
- toastHeader: '',
examsData: null,
errorMessage: null,
errorCode: null,
@@ -46,16 +43,6 @@ const slice = createSlice({
state.targetUserId = payload.targetUserId;
state.courseStatus = LOADED;
},
- setCallToActionToast: (state, { payload }) => {
- const {
- header,
- link,
- linkText,
- } = payload;
- state.toastBodyLink = link;
- state.toastBodyText = linkText;
- state.toastHeader = header;
- },
setExamsData: (state, { payload }) => {
state.examsData = payload;
},
@@ -68,7 +55,6 @@ export const {
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
- setCallToActionToast,
setExamsData,
} = slice.actions;
diff --git a/src/course-home/data/slice.test.js b/src/course-home/data/slice.test.js
index 336aa4f116..b6cf60dc5b 100644
--- a/src/course-home/data/slice.test.js
+++ b/src/course-home/data/slice.test.js
@@ -9,9 +9,6 @@ describe('course home data slice', () => {
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
- toastBodyText: '',
- toastBodyLink: null,
- toastHeader: '',
examsData: null,
};
@@ -47,9 +44,6 @@ describe('course home data slice', () => {
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
- toastBodyText: '',
- toastBodyLink: null,
- toastHeader: '',
examsData: [{ id: 1, examName: 'Old Exam' }],
};
@@ -76,9 +70,6 @@ describe('course home data slice', () => {
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
- toastBodyText: '',
- toastBodyLink: null,
- toastHeader: '',
examsData: [{ id: 1, examName: 'Some Exam' }],
};
@@ -95,9 +86,6 @@ describe('course home data slice', () => {
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'loading',
tabFetchStates: {},
- toastBodyText: '',
- toastBodyLink: null,
- toastHeader: '',
examsData: [{ id: 1, examName: 'Some Exam' }],
};
@@ -114,9 +102,6 @@ describe('course home data slice', () => {
metadataModel: 'courseHomeCourseMetadata',
proctoringPanelStatus: 'complete',
tabFetchStates: { progress: 'loaded' },
- toastBodyText: 'Toast message',
- toastBodyLink: 'http://example.com',
- toastHeader: 'Toast Header',
examsData: null,
};
@@ -133,7 +118,6 @@ describe('course home data slice', () => {
// Verify other properties remain unchanged
expect(newState.courseStatus).toBe(initialState.courseStatus);
expect(newState.courseId).toBe(initialState.courseId);
- expect(newState.toastBodyText).toBe(initialState.toastBodyText);
});
});
});
diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js
index 7a32dd72d1..7a3e665845 100644
--- a/src/course-home/data/thunks.js
+++ b/src/course-home/data/thunks.js
@@ -1,13 +1,10 @@
import { logError } from '@edx/frontend-platform/logging';
-import { camelCaseObject } from '@edx/frontend-platform';
import {
- executePostFromPostEvent,
getCourseHomeCourseMetadata,
getDatesTabData,
getExamsData,
getOutlineTabData,
getProgressTabData,
- postCourseDeadlines,
deprecatedPostCourseGoals,
postWeeklyLearningGoal,
postDismissWelcomeMessage,
@@ -24,11 +21,10 @@ import {
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
- setCallToActionToast,
setExamsData,
} from './slice';
-const eventTypes = {
+export const eventTypes = {
POST_EVENT: 'post_event',
};
@@ -118,21 +114,6 @@ export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}
-export function resetDeadlines(courseId, model, getTabData) {
- return async (dispatch) => {
- postCourseDeadlines(courseId, model).then(response => {
- const { data } = response;
- const {
- header,
- link,
- link_text: linkText,
- } = data;
- dispatch(getTabData(courseId));
- dispatch(setCallToActionToast({ header, link, linkText }));
- });
- };
-}
-
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
return deprecatedPostCourseGoals(courseId, goalKey);
}
@@ -141,27 +122,6 @@ export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedTo
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
}
-export function processEvent(eventData, getTabData) {
- return async (dispatch) => {
- // Pulling this out early so the data doesn't get camelCased and is easier
- // to use when it's passed to the backend
- const { research_event_data: researchEventData } = eventData;
- const event = camelCaseObject(eventData);
- if (event.eventName === eventTypes.POST_EVENT) {
- executePostFromPostEvent(event.postData, researchEventData).then(response => {
- const { data } = response;
- const {
- header,
- link,
- link_text: linkText,
- } = data;
- dispatch(getTabData(event.postData.bodyParams.courseId));
- dispatch(setCallToActionToast({ header, link, linkText }));
- });
- }
- };
-}
-
export function fetchExamAttemptsData(courseId, sequenceIds) {
return async (dispatch) => {
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
diff --git a/src/course-home/dates-tab/DatesTab.test.jsx b/src/course-home/dates-tab/DatesTab.test.jsx
index 24ca795349..88a0d4e790 100644
--- a/src/course-home/dates-tab/DatesTab.test.jsx
+++ b/src/course-home/dates-tab/DatesTab.test.jsx
@@ -20,6 +20,7 @@ import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { appendBrowserTimezoneToUrl } from '../../utils';
import { UserMessagesProvider } from '../../generic/user-messages';
+import { ToastProvider } from '../../generic/ToastContext';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -36,16 +37,18 @@ describe('DatesTab', () => {
-
-
-
-
- )}
- />
-
+
+
+
+
+
+ )}
+ />
+
+
diff --git a/src/course-home/discussion-tab/DiscussionTab.test.jsx b/src/course-home/discussion-tab/DiscussionTab.test.jsx
index f13f3aeff2..8e1db24705 100644
--- a/src/course-home/discussion-tab/DiscussionTab.test.jsx
+++ b/src/course-home/discussion-tab/DiscussionTab.test.jsx
@@ -8,6 +8,7 @@ import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
+import { ToastProvider } from '../../generic/ToastContext';
import {
createTestQueryClient, initializeMockApp, messageEvent, screen, waitFor,
} from '../../setupTest';
@@ -32,16 +33,18 @@ describe('DiscussionTab', () => {
-
-
-
-
- )}
- />
-
+
+
+
+
+
+ )}
+ />
+
+
diff --git a/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx b/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx
index 06edda53b6..a856c96aca 100644
--- a/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx
+++ b/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx
@@ -10,7 +10,7 @@ import {
Col,
} from '@openedx/paragon';
-import { resetDeadlines } from '../data';
+import { useResetDeadlines } from '../data/apiHooks';
import { useModel } from '../../generic/model-store';
import messages from './messages';
@@ -31,6 +31,7 @@ const ShiftDatesAlert = ({ fetch, model }) => {
} = datesBannerInfo;
const dispatch = useDispatch();
+ const resetDeadlines = useResetDeadlines();
if (!missedDeadlines || missedGatedContent || hasEnded) {
return null;
@@ -48,7 +49,10 @@ const ShiftDatesAlert = ({ fetch, model }) => {
variant="primary"
size="sm"
className="w-xs-100 w-md-auto"
- onClick={() => dispatch(resetDeadlines(courseId, model, fetch))}
+ onClick={() => resetDeadlines.mutate(
+ { courseId, model },
+ { onSuccess: () => dispatch(fetch(courseId)) },
+ )}
>
{intl.formatMessage(messages.shiftDatesButton)}
diff --git a/src/courseware/CoursewareContainer.test.jsx b/src/courseware/CoursewareContainer.test.jsx
index 287a586b9d..8e00d693c9 100644
--- a/src/courseware/CoursewareContainer.test.jsx
+++ b/src/courseware/CoursewareContainer.test.jsx
@@ -13,6 +13,7 @@ import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
+import { ToastProvider } from '../generic/ToastContext';
import tabMessages from '../tab-page/messages';
import { createTestQueryClient, initializeMockApp, waitFor } from '../setupTest';
import { DECODE_ROUTES } from '../constants';
@@ -95,15 +96,17 @@ describe('CoursewareContainer', () => {
-
- {DECODE_ROUTES.COURSEWARE.map((route) => (
- }
- />
- ))}
-
+
+
+ {DECODE_ROUTES.COURSEWARE.map((route) => (
+ }
+ />
+ ))}
+
+
diff --git a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js
index 34e56c22e4..cdc1b12302 100644
--- a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js
+++ b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js
@@ -6,7 +6,6 @@ import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { fetchCourse } from '@src/courseware/data';
-import { processEvent } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks';
import { useSequenceNavigationMetadata } from '@src/courseware/course/sequence/sequence-navigation/hooks';
@@ -15,8 +14,10 @@ import { messageTypes } from '../constants';
import useIFrameBehavior, { iframeBehaviorState } from './useIFrameBehavior';
const mockNavigate = jest.fn();
+const mockMutate = jest.fn();
jest.mock('@edx/frontend-platform', () => ({
+ ...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(),
}));
@@ -40,7 +41,10 @@ jest.mock('@src/courseware/data', () => ({
fetchCourse: jest.fn(),
}));
jest.mock('@src/course-home/data/thunks', () => ({
- processEvent: jest.fn((...args) => ({ processEvent: args })),
+ eventTypes: { POST_EVENT: 'post_event' },
+}));
+jest.mock('@src/course-home/data/apiHooks', () => ({
+ usePostEvent: () => ({ mutate: mockMutate }),
}));
jest.mock('@src/generic/hooks', () => ({
useEventListener: jest.fn(),
@@ -349,12 +353,30 @@ describe('useIFrameBehavior hook', () => {
});
it('registers an event handler to process fetchCourse events.', () => {
mockState(defaultStateVals);
+ fetchCourse.mockReturnValue('fetch-course-action');
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
- const eventName = 'test-event-name';
- const event = { data: { event_name: eventName } };
+ const event = {
+ data: {
+ event_name: 'post_event',
+ post_data: { url: 'post-url', body_params: { course_id: 'course-1' } },
+ research_event_data: { location: 'unit' },
+ },
+ };
window.onmessage(event);
- expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
+
+ expect(mockMutate).toHaveBeenCalledWith(
+ {
+ postData: { url: 'post-url', bodyParams: { courseId: 'course-1' } },
+ researchEventData: { location: 'unit' },
+ },
+ { onSuccess: expect.any(Function) },
+ );
+
+ const { onSuccess } = mockMutate.mock.calls[0][1];
+ onSuccess();
+ expect(fetchCourse).toHaveBeenCalledWith('course-1');
+ expect(dispatch).toHaveBeenCalledWith('fetch-course-action');
});
it('updates initial iframe visibility on load', () => {
const { result } = renderHook(() => useIFrameBehavior(props));
diff --git a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts
index 4a882da321..91c45c5a59 100644
--- a/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts
+++ b/src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
-import { getConfig } from '@edx/frontend-platform';
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
@@ -8,7 +8,8 @@ import { throttle } from 'lodash';
import { logError } from '@edx/frontend-platform/logging';
import { fetchCourse } from '@src/courseware/data';
-import { processEvent } from '@src/course-home/data/thunks';
+import { usePostEvent } from '@src/course-home/data/apiHooks';
+import { eventTypes } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks';
import { getSequenceId } from '@src/courseware/data/selectors';
import { useModel } from '@src/generic/model-store';
@@ -34,6 +35,7 @@ const useIFrameBehavior = ({
useLoadBearingHook(id);
const dispatch = useDispatch();
+ const postEvent = usePostEvent();
const activeSequenceId = useSelector(getSequenceId);
const navigate = useNavigate();
const activeSequence = useModel('sequences', activeSequenceId);
@@ -150,6 +152,22 @@ const useIFrameBehavior = ({
* could have given us a 4xx or 5xx response.
*/
+ const handlePostMessageEvent = (e: MessageEvent) => {
+ if (!e.data?.event_name) {
+ return;
+ }
+ // Pull this out before camelCasing so it stays in the shape the backend expects.
+ const { research_event_data: researchEventData } = e.data;
+ const event = camelCaseObject(e.data);
+ if (event.eventName !== eventTypes.POST_EVENT) {
+ return;
+ }
+ postEvent.mutate(
+ { postData: event.postData, researchEventData },
+ { onSuccess: () => dispatch(fetchCourse(event.postData.bodyParams.courseId)) },
+ );
+ };
+
const handleIFrameLoad = () => {
if (!hasLoaded) {
setShowError(true);
@@ -161,11 +179,7 @@ const useIFrameBehavior = ({
iframeUrl,
});
}
- window.onmessage = (e) => {
- if (e.data.event_name) {
- dispatch(processEvent(e.data, fetchCourse));
- }
- };
+ window.onmessage = handlePostMessageEvent;
// Update the visibility of the iframe in case the element is already visible.
updateIframeVisibility();
diff --git a/src/generic/ToastContext.test.tsx b/src/generic/ToastContext.test.tsx
new file mode 100644
index 0000000000..c39029cdda
--- /dev/null
+++ b/src/generic/ToastContext.test.tsx
@@ -0,0 +1,45 @@
+import { act, renderHook } from '@testing-library/react';
+
+import { ToastProvider, useToast } from './ToastContext';
+
+const wrapper = ({ children }) => {children};
+
+describe('ToastContext', () => {
+ it('throws when used outside a provider', () => {
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
+ expect(() => renderHook(() => useToast())).toThrow(
+ 'useToast must be used within a ToastProvider',
+ );
+ consoleError.mockRestore();
+ });
+
+ it('starts with no content and closed', () => {
+ const { result } = renderHook(() => useToast(), { wrapper });
+ expect(result.current.toastContent).toBeNull();
+ expect(result.current.isToastOpen).toBe(false);
+ });
+
+ it('setToastContent sets the content without opening the toast', () => {
+ const { result } = renderHook(() => useToast(), { wrapper });
+ const content = { message: 'hello', action: { label: 'go', href: '/x' } };
+
+ act(() => result.current.setToastContent(content));
+
+ expect(result.current.toastContent).toEqual(content);
+ expect(result.current.isToastOpen).toBe(false);
+ });
+
+ it('openToast and closeToast toggle visibility without touching content', () => {
+ const { result } = renderHook(() => useToast(), { wrapper });
+ const content = { message: 'hello' };
+ act(() => result.current.setToastContent(content));
+
+ act(() => result.current.openToast());
+ expect(result.current.isToastOpen).toBe(true);
+ expect(result.current.toastContent).toEqual(content);
+
+ act(() => result.current.closeToast());
+ expect(result.current.isToastOpen).toBe(false);
+ expect(result.current.toastContent).toEqual(content);
+ });
+});
diff --git a/src/generic/ToastContext.tsx b/src/generic/ToastContext.tsx
new file mode 100644
index 0000000000..3fac4e648b
--- /dev/null
+++ b/src/generic/ToastContext.tsx
@@ -0,0 +1,41 @@
+import React, {
+ createContext, useContext, useMemo, useState, ReactNode,
+} from 'react';
+
+export interface ToastContent {
+ message: string;
+ action?: { label: string; href?: string; onClick?: () => void };
+}
+
+interface ToastContextValue {
+ toastContent: ToastContent | null;
+ setToastContent: (toast: ToastContent | null) => void;
+ isToastOpen: boolean;
+ openToast: () => void;
+ closeToast: () => void;
+}
+
+const ToastContext = createContext(null);
+
+export const ToastProvider = ({ children }: { children: ReactNode }) => {
+ const [toastContent, setToastContent] = useState(null);
+ const [isToastOpen, setIsToastOpen] = useState(false);
+
+ const value = useMemo(() => ({
+ toastContent,
+ setToastContent,
+ isToastOpen,
+ openToast: () => setIsToastOpen(true),
+ closeToast: () => setIsToastOpen(false),
+ }), [toastContent, isToastOpen]);
+
+ return {children};
+};
+
+export const useToast = (): ToastContextValue => {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within a ToastProvider');
+ }
+ return context;
+};
diff --git a/src/index.jsx b/src/index.jsx
index eb0b8fddad..75f7f0c1d2 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -31,6 +31,7 @@ import { fetchCourse } from './courseware/data';
import { store } from './store';
import NoticesProvider from './generic/notices';
import PathFixesProvider from './generic/path-fixes';
+import { ToastProvider } from './generic/ToastContext';
import LiveTab from './course-home/live-tab/LiveTab';
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
import DecodePageRoute from './decode-page-route';
@@ -54,100 +55,102 @@ subscribe(APP_READY, () => {
-
- } />
- } />
- } />
-
+
+
+ } />
+ } />
+ } />
+
}
- />
- }
- />
-
-
-
-
-
- )}
- />
-
-
-
-
-
- )}
- />
-
-
-
-
-
- )}
- />
-
-
-
-
-
+ />
+ }
+ />
+
+
+
+
+
)}
- />
- {DECODE_ROUTES.PROGRESS.map((route) => (
+ />
-
-
+
+
- )}
+ )}
/>
- ))}
-
-
-
-
-
+
+
+
+
+
)}
- />
- {DECODE_ROUTES.COURSEWARE.map((route) => (
+ />
-
+
+
+
+ )}
+ />
+ {DECODE_ROUTES.PROGRESS.map((route) => (
+
+
+
+
+
)}
+ />
+ ))}
+
+
+
+
+
+ )}
/>
- ))}
-
+ {DECODE_ROUTES.COURSEWARE.map((route) => (
+
+
+
+ )}
+ />
+ ))}
+
+
diff --git a/src/setupTest.js b/src/setupTest.js
index 3f61b493c8..2459cb1753 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -17,6 +17,7 @@ import { reducer as courseHomeReducer } from './course-home/data';
import { reducer as coursewareReducer } from './courseware/data/slice';
import { reducer as modelsReducer } from './generic/model-store';
import { UserMessagesProvider } from './generic/user-messages';
+import { ToastProvider } from './generic/ToastContext';
import messages from './i18n';
import { fetchCourse, fetchSequence } from './courseware/data';
@@ -266,7 +267,9 @@ function render(
- {children}
+
+ {children}
+
diff --git a/src/tab-page/TabPage.jsx b/src/tab-page/TabPage.jsx
index 494db7fa11..861713741f 100644
--- a/src/tab-page/TabPage.jsx
+++ b/src/tab-page/TabPage.jsx
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { useDispatch, useSelector } from 'react-redux';
+import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
import { Toast } from '@openedx/paragon';
@@ -10,11 +10,11 @@ import HeaderSlot from '../plugin-slots/HeaderSlot';
import PageLoading from '../generic/PageLoading';
import { getAccessDeniedRedirectUrl } from '../shared/access';
import { useModel } from '../generic/model-store';
+import { useToast } from '../generic/ToastContext';
import genericMessages from '../generic/messages';
import messages from './messages';
import LoadedTabPage from './LoadedTabPage';
-import { setCallToActionToast } from '../course-home/data/slice';
import LaunchCourseHomeTourButton from '../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
import { TourProvider } from '../product-tours/TourContext';
@@ -27,16 +27,13 @@ const TabPage = (props) => {
metadataModel,
} = props;
const {
- toastBodyLink,
- toastBodyText,
- toastHeader,
errorMessage: courseHomeErrorMessage,
} = useSelector(state => state.courseHome);
const {
errorMessage: coursewareErrorMessage,
} = useSelector(state => state.courseware);
const errorMessage = courseHomeErrorMessage || coursewareErrorMessage;
- const dispatch = useDispatch();
+ const { toastContent, isToastOpen, closeToast } = useToast();
const {
courseAccess,
number,
@@ -57,15 +54,12 @@ const TabPage = (props) => {
{['loaded', 'denied'].includes(courseStatus) && (
<>
dispatch(setCallToActionToast({ header: '', link: null, link_text: null }))}
- show={!!(toastHeader)}
+ onClose={closeToast}
+ show={isToastOpen}
>
- {toastHeader}
+ {toastContent?.message}
{metadataModel === 'courseHomeMeta' && ()}
>
diff --git a/src/tab-page/TabPage.test.jsx b/src/tab-page/TabPage.test.jsx
index d915b92cb8..8e7e9650c3 100644
--- a/src/tab-page/TabPage.test.jsx
+++ b/src/tab-page/TabPage.test.jsx
@@ -1,19 +1,27 @@
import React from 'react';
-import { getConfig } from '@edx/frontend-platform';
-import MockAdapter from 'axios-mock-adapter';
-import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
- initializeTestStore, logUnhandledRequests, render, screen,
+ initializeTestStore, render, screen,
} from '../setupTest';
import { TabPage } from './index';
-import { executeThunk } from '../utils';
-import * as thunks from '../course-home/data/thunks';
+import { useToast } from '../generic/ToastContext';
// We should not test `LoadedTabPage` page here, as `TabPage` is used only for passing `passthroughProps`.
jest.mock('./LoadedTabPage', () => function () {
return ;
});
+jest.mock('../generic/ToastContext', () => ({
+ ...jest.requireActual('../generic/ToastContext'),
+ useToast: jest.fn(),
+}));
+
+const mockUseToast = (overrides = {}) => useToast.mockReturnValue({
+ toastContent: null,
+ isToastOpen: false,
+ closeToast: jest.fn(),
+ ...overrides,
+});
+
describe('Tab Page', () => {
const mockData = {
courseStatus: 'loaded',
@@ -23,6 +31,10 @@ describe('Tab Page', () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
});
+ beforeEach(() => {
+ mockUseToast();
+ });
+
it('displays loading message', () => {
render(, { wrapWithRouter: true });
expect(screen.getByText('Loading course pageā¦')).toBeInTheDocument();
@@ -69,26 +81,12 @@ describe('Tab Page', () => {
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
});
- it('displays Learning Toast', async () => {
- const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
- render(, { store: testStore, wrapWithRouter: true });
-
- const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
- const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
- axiosMock.onPost(resetUrl).reply(201, {
- link: 'test-toast-link',
- link_text: 'test-toast-body',
- header: 'test-toast-header',
+ it('renders a toast from the toast context', () => {
+ mockUseToast({
+ toastContent: { message: 'test-toast-header', action: { label: 'test-toast-body', href: 'test-toast-link' } },
+ isToastOpen: true,
});
- logUnhandledRequests(axiosMock);
-
- const getTabDataMock = jest.fn(() => ({
- type: 'MOCK_ACTION',
- }));
- const model = 'outline';
-
- await executeThunk(thunks.resetDeadlines('courseId', model, getTabDataMock), testStore.dispatch);
-
+ render(, { wrapWithRouter: true });
expect(screen.getByText('test-toast-header')).toBeInTheDocument();
expect(screen.getByText('test-toast-body')).toBeInTheDocument();
});