From f39baa24819381b75387f021e82a6a95abe60daa Mon Sep 17 00:00:00 2001 From: Brian Smith Date: Thu, 6 Aug 2026 13:26:22 -0400 Subject: [PATCH] refactor: convert course-home CTA toast from Redux to a React ToastProvider The shared TabPage read the call-to-action toast from the courseHome Redux slice, so no course-home tab could be de-Redux'd while the toast stayed in Redux. This extracts it end to end (part of #1946, Phase 3): - Toast client state -> a new React ToastProvider/useToast context (src/generic/ToastContext.tsx), mounted at the route root. Message and visibility are decoupled (setToastContent vs openToast/closeToast), removing the slice's show={!!toastHeader} content/visibility fusion. TabPage renders the single from the context. - The two POST writers that feed it -> React Query mutations in src/course-home/data/apiHooks.ts (useResetDeadlines, usePostEvent), following the product-tours mutation pattern. The hooks own only the POST + toast and carry no Redux; the transitional model-store refresh stays in each caller's mutate onSuccess (dispatch(getTabData)) until that data is RQ, keeping apiHooks.ts Redux-free. - Deletes the resetDeadlines/processEvent thunks (and the index.js re-export) and the setCallToActionToast reducer + toast fields from the slice. The processEvent postMessage parse/guard moves into useIFrameBehavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/course-home/data/apiHooks.test.tsx | 119 +++++++++++++ src/course-home/data/apiHooks.ts | 47 +++++ src/course-home/data/index.js | 1 - src/course-home/data/redux.test.js | 19 --- src/course-home/data/slice.js | 14 -- src/course-home/data/slice.test.js | 16 -- src/course-home/data/thunks.js | 42 +---- src/course-home/dates-tab/DatesTab.test.jsx | 23 +-- .../discussion-tab/DiscussionTab.test.jsx | 23 +-- .../ShiftDatesAlert.jsx | 8 +- src/courseware/CoursewareContainer.test.jsx | 21 ++- .../Unit/hooks/useIFrameBehavior.test.js | 32 +++- .../sequence/Unit/hooks/useIFrameBehavior.ts | 28 ++- src/generic/ToastContext.test.tsx | 45 +++++ src/generic/ToastContext.tsx | 41 +++++ src/index.jsx | 161 +++++++++--------- src/setupTest.js | 5 +- src/tab-page/TabPage.jsx | 20 +-- src/tab-page/TabPage.test.jsx | 48 +++--- 19 files changed, 461 insertions(+), 252 deletions(-) create mode 100644 src/course-home/data/apiHooks.test.tsx create mode 100644 src/course-home/data/apiHooks.ts create mode 100644 src/generic/ToastContext.test.tsx create mode 100644 src/generic/ToastContext.tsx 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(); });