diff --git a/src/course-home/data/api.test.js b/src/course-home/data/api.test.js index dc40bf1946..4889611d7a 100644 --- a/src/course-home/data/api.test.js +++ b/src/course-home/data/api.test.js @@ -1,7 +1,7 @@ import { getConfig, setConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import MockAdapter from 'axios-mock-adapter'; -import { getTimeOffsetMillis, getExamsData } from './api'; +import { getDatesTabData, getExamsData, getTimeOffsetMillis } from './api'; import { initializeMockApp } from '../../setupTest'; initializeMockApp(); @@ -175,3 +175,32 @@ describe('getExamsData', () => { expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence'); }); }); + +describe('getDatesTabData', () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`; + + beforeEach(() => { + axiosMock.reset(); + }); + + it('returns camelCased data on success', async () => { + axiosMock.onGet(datesUrl).reply(200, { course_date_blocks: [] }); + await expect(getDatesTabData(courseId)).resolves.toEqual({ courseDateBlocks: [] }); + }); + + it('swallows a 401 and resolves to an empty object', async () => { + axiosMock.onGet(datesUrl).reply(401); + await expect(getDatesTabData(courseId)).resolves.toEqual({}); + }); + + it('swallows a 403 and resolves to an empty object', async () => { + axiosMock.onGet(datesUrl).reply(403); + await expect(getDatesTabData(courseId)).resolves.toEqual({}); + }); + + it('re-throws other errors', async () => { + axiosMock.onGet(datesUrl).reply(500); + await expect(getDatesTabData(courseId)).rejects.toThrow(); + }); +}); diff --git a/src/course-home/data/apiHooks.ts b/src/course-home/data/apiHooks.ts index 22a9162f2e..79bf434890 100644 --- a/src/course-home/data/apiHooks.ts +++ b/src/course-home/data/apiHooks.ts @@ -1,8 +1,11 @@ import { logError } from '@edx/frontend-platform/logging'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useToast, ToastContent } from '@src/generic/ToastContext'; -import { executePostFromPostEvent, postCourseDeadlines } from './api'; +import { + executePostFromPostEvent, getCourseHomeCourseMetadata, getDatesTabData, postCourseDeadlines, +} from './api'; +import { courseHomeQueryKeys } from './queryKeys'; interface CallToActionResponse { header: string; @@ -45,3 +48,15 @@ export const usePostEvent = () => { onError: (error) => logError(error), }); }; + +export const useCourseHomeMeta = (courseId: string) => useQuery({ + queryKey: courseHomeQueryKeys.metadata(courseId), + queryFn: () => getCourseHomeCourseMetadata(courseId, 'outline'), + meta: { modelType: 'courseHomeMeta', courseId }, +}); + +export const useDatesTabData = (courseId: string) => useQuery({ + queryKey: courseHomeQueryKeys.datesTab(courseId), + queryFn: () => getDatesTabData(courseId), + meta: { modelType: 'dates', courseId }, +}); diff --git a/src/course-home/data/index.js b/src/course-home/data/index.js index af1529f0dc..7ac41d169c 100644 --- a/src/course-home/data/index.js +++ b/src/course-home/data/index.js @@ -1,5 +1,4 @@ export { - fetchDatesTab, fetchOutlineTab, fetchProgressTab, deprecatedSaveCourseGoal, diff --git a/src/course-home/data/queryKeys.ts b/src/course-home/data/queryKeys.ts new file mode 100644 index 0000000000..f8f97e53d3 --- /dev/null +++ b/src/course-home/data/queryKeys.ts @@ -0,0 +1,7 @@ +import { appId } from '@src/constants'; + +export const courseHomeQueryKeys = { + all: [appId, 'courseHome'] as const, + metadata: (courseId: string) => [...courseHomeQueryKeys.all, 'metadata', courseId] as const, + datesTab: (courseId: string) => [...courseHomeQueryKeys.all, 'datesTab', courseId] as const, +}; diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js index 7de295731a..1743b3e517 100644 --- a/src/course-home/data/redux.test.js +++ b/src/course-home/data/redux.test.js @@ -42,95 +42,6 @@ describe('Data layer integration tests', () => { store = initializeStore(); }); - describe('Test fetchDatesTab', () => { - const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates`; - - it('Should fail to fetch if error occurs', async () => { - axiosMock.onGet(courseMetadataUrl).networkError(); - axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError(); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - expect(loggingService.logError).toHaveBeenCalled(); - expect(store.getState().courseHome.courseStatus).toEqual('failed'); - }); - - it('should store errorMessage and errorCode from a 403 catalog visibility response', async () => { - const errorDetail = 'This course is not currently accessible. The course team has restricted access to this content.'; - const errorCode = 'not_visible_in_catalog'; - axiosMock.onGet(courseMetadataUrl).reply(403, { detail: errorDetail, error_code: errorCode }); - axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(200, Factory.build('datesTabData')); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - const { courseHome } = store.getState(); - expect(courseHome.courseStatus).toEqual('failed'); - expect(courseHome.errorMessage).toEqual(errorDetail); - expect(courseHome.errorCode).toEqual(errorCode); - }); - - it('should not store errorMessage for non-403 errors', async () => { - axiosMock.onGet(courseMetadataUrl).networkError(); - axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError(); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - const { courseHome } = store.getState(); - expect(courseHome.courseStatus).toEqual('failed'); - expect(courseHome.errorMessage).toBeNull(); - expect(courseHome.errorCode).toBeNull(); - }); - - it('should result in fetch failed if course metadata call errored', async () => { - const datesTabData = Factory.build('datesTabData'); - const datesUrl = `${datesBaseUrl}/${courseId}`; - - axiosMock.onGet(courseMetadataUrl).networkError(); - axiosMock.onGet(datesUrl).reply(200, datesTabData); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - expect(loggingService.logError).toHaveBeenCalled(); - expect(store.getState().courseHome.courseStatus).toEqual('failed'); - }); - - it('should result in fetch failed if course metadata call errored', async () => { - axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata); - axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError(); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - expect(loggingService.logError).toHaveBeenCalled(); - expect(store.getState().courseHome.courseStatus).toEqual('failed'); - }); - - it('Should fetch, normalize, and save metadata', async () => { - const datesTabData = Factory.build('datesTabData'); - - const datesUrl = `${datesBaseUrl}/${courseId}`; - - axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata); - axiosMock.onGet(datesUrl).reply(200, datesTabData); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - const state = store.getState(); - expect(state.courseHome.courseStatus).toEqual('loaded'); - }); - - it.each([401, 403, 404])( - 'should result in fetch denied if course access is denied, regardless of dates API status', - async (errorStatus) => { - axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata); - axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {}); - - await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch); - - expect(store.getState().courseHome.courseStatus).toEqual('denied'); - }, - ); - }); - describe('Test fetchOutlineTab', () => { const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`; const outlineUrl = `${outlineBaseUrl}/${courseId}`; diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js index 7a3e665845..48acab1e02 100644 --- a/src/course-home/data/thunks.js +++ b/src/course-home/data/thunks.js @@ -1,7 +1,6 @@ import { logError } from '@edx/frontend-platform/logging'; import { getCourseHomeCourseMetadata, - getDatesTabData, getExamsData, getOutlineTabData, getProgressTabData, @@ -86,10 +85,6 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) { }; } -export function fetchDatesTab(courseId) { - return fetchTab(courseId, 'dates', getDatesTabData); -} - export function fetchProgressTab(courseId, targetUserId) { return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId); } diff --git a/src/course-home/dates-tab/DatesTab.jsx b/src/course-home/dates-tab/DatesTab.jsx index c7d3fcbe55..e9248a3f59 100644 --- a/src/course-home/dates-tab/DatesTab.jsx +++ b/src/course-home/dates-tab/DatesTab.jsx @@ -1,13 +1,14 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import Timeline from './timeline/Timeline'; -import { fetchDatesTab } from '../data'; +import { useCourseHomeMeta, useDatesTabData } from '../data/apiHooks'; import { useModel } from '../../generic/model-store'; +import { TabWithTimer } from '../../tab-page'; import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader'; import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert'; @@ -16,9 +17,10 @@ import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToS const DatesTab = () => { const intl = useIntl(); - const { - courseId, - } = useSelector(state => state.courseHome); + const { courseId } = useParams(); + + const metadataQuery = useCourseHomeMeta(courseId); + const tabDataQuery = useDatesTabData(courseId); const { isSelfPaced, @@ -43,20 +45,25 @@ const DatesTab = () => { }; return ( - <> +
{intl.formatMessage(messages.title)}
{isSelfPaced && hasDeadlines && ( <> - + )} - +
); }; diff --git a/src/course-home/dates-tab/DatesTab.test.jsx b/src/course-home/dates-tab/DatesTab.test.jsx index 88a0d4e790..486d2a83a0 100644 --- a/src/course-home/dates-tab/DatesTab.test.jsx +++ b/src/course-home/dates-tab/DatesTab.test.jsx @@ -12,12 +12,10 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import DatesTab from './DatesTab'; -import { fetchDatesTab } from '../data'; import { createTestQueryClient, fireEvent, initializeMockApp, waitFor, } from '../../setupTest'; import initializeStore from '../../store'; -import { TabContainer } from '../../tab-page'; import { appendBrowserTimezoneToUrl } from '../../utils'; import { UserMessagesProvider } from '../../generic/user-messages'; import { ToastProvider } from '../../generic/ToastContext'; @@ -35,17 +33,13 @@ describe('DatesTab', () => { store = initializeStore(); component = ( - + - - - )} + element={} /> diff --git a/src/course-home/dates-tab/timeline/Day.jsx b/src/course-home/dates-tab/timeline/Day.jsx index 2d70c50ad2..9edb6c2444 100644 --- a/src/course-home/dates-tab/timeline/Day.jsx +++ b/src/course-home/dates-tab/timeline/Day.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { FormattedDate, FormattedTime, @@ -23,9 +23,7 @@ const Day = ({ last, }) => { const intl = useIntl(); - const { - courseId, - } = useSelector(state => state.courseHome); + const { courseId } = useParams(); const { userTimezone, } = useModel('courseHomeMeta', courseId); diff --git a/src/course-home/dates-tab/timeline/Timeline.jsx b/src/course-home/dates-tab/timeline/Timeline.jsx index 09073d6fb7..8d726cd6fe 100644 --- a/src/course-home/dates-tab/timeline/Timeline.jsx +++ b/src/course-home/dates-tab/timeline/Timeline.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { useModel } from '../../../generic/model-store'; @@ -7,9 +7,7 @@ import Day from './Day'; import { daycmp, isLearnerAssignment } from '../utils'; const Timeline = () => { - const { - courseId, - } = useSelector(state => state.courseHome); + const { courseId } = useParams(); const { courseDateBlocks, diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index eed7f35ccb..5e9bffaf33 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -2,7 +2,7 @@ * @jest-environment jsdom */ import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { Factory } from 'rosie'; import { getConfig } from '@edx/frontend-platform'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -73,10 +73,13 @@ describe('Outline Tab', () => { async function fetchAndRender(path = '') { await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + const search = path.includes('?') ? path.slice(path.indexOf('?')) : ''; await act(async () => render( - + - + + } /> + , { store }, @@ -196,6 +199,18 @@ describe('Outline Tab', () => { }); describe('Suggested schedule alerts', () => { + const dateBlocks = [ + { + assignment_type: 'Homework', + date: '2010-08-20T05:59:40.942669Z', + date_type: 'assignment-due-date', + description: '', + learner_has_access: true, + title: 'Missed assignment', + extra_info: null, + }, + ]; + beforeEach(() => { setMetadata({ is_enrolled: true, is_self_paced: true }); setTabData({ @@ -205,19 +220,7 @@ describe('Outline Tab', () => { missed_gated_content: true, verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5', }, - }, { - date_blocks: [ - { - assignment_type: 'Homework', - date: '2010-08-20T05:59:40.942669Z', - date_type: 'assignment-due-date', - description: '', - learner_has_access: true, - title: 'Missed assignment', - extra_info: null, - }, - ], - }); + }, { date_blocks: dateBlocks }); }); it('renders UpgradeToShiftDatesAlert', async () => { @@ -245,6 +248,28 @@ describe('Outline Tab', () => { pageName: 'course_home', }); }); + + it('handles shift due dates click', async () => { + const user = userEvent.setup(); + setTabData( + { dates_banner_info: { missed_deadlines: true, missed_gated_content: false } }, + { date_blocks: dateBlocks }, + ); + await fetchAndRender(); + + const button = await screen.findByRole('button', { name: 'Shift due dates' }); + + axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`) + .reply(200, { header: 'Dates shifted' }); + setTabData( + { dates_banner_info: { missed_deadlines: false, missed_gated_content: false } }, + { date_blocks: dateBlocks }, + ); + + await user.click(button); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument()); + }); }); describe('Welcome Message', () => { diff --git a/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx b/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx index a856c96aca..b1c82d1f0f 100644 --- a/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx +++ b/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx @@ -1,5 +1,7 @@ import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -11,14 +13,15 @@ import { } from '@openedx/paragon'; import { useResetDeadlines } from '../data/apiHooks'; +import { courseHomeQueryKeys } from '../data/queryKeys'; import { useModel } from '../../generic/model-store'; import messages from './messages'; const ShiftDatesAlert = ({ fetch, model }) => { const intl = useIntl(); - const { - courseId, - } = useSelector(state => state.courseHome); + const { courseId } = useParams(); + const queryClient = useQueryClient(); + const dispatch = useDispatch(); const { datesBannerInfo, @@ -30,13 +33,19 @@ const ShiftDatesAlert = ({ fetch, model }) => { missedGatedContent, } = datesBannerInfo; - const dispatch = useDispatch(); const resetDeadlines = useResetDeadlines(); if (!missedDeadlines || missedGatedContent || hasEnded) { return null; } + const refreshTabData = () => { + queryClient.invalidateQueries({ queryKey: courseHomeQueryKeys.datesTab(courseId) }); + if (fetch) { + dispatch(fetch(courseId)); + } + }; + return ( @@ -51,7 +60,7 @@ const ShiftDatesAlert = ({ fetch, model }) => { className="w-xs-100 w-md-auto" onClick={() => resetDeadlines.mutate( { courseId, model }, - { onSuccess: () => dispatch(fetch(courseId)) }, + { onSuccess: refreshTabData }, )} > {intl.formatMessage(messages.shiftDatesButton)} @@ -63,8 +72,12 @@ const ShiftDatesAlert = ({ fetch, model }) => { }; ShiftDatesAlert.propTypes = { - fetch: PropTypes.func.isRequired, + fetch: PropTypes.func, model: PropTypes.string.isRequired, }; +ShiftDatesAlert.defaultProps = { + fetch: undefined, +}; + export default ShiftDatesAlert; diff --git a/src/course-home/suggested-schedule-messaging/UpgradeToCompleteAlert.jsx b/src/course-home/suggested-schedule-messaging/UpgradeToCompleteAlert.jsx index b74099eda6..afb22628fd 100644 --- a/src/course-home/suggested-schedule-messaging/UpgradeToCompleteAlert.jsx +++ b/src/course-home/suggested-schedule-messaging/UpgradeToCompleteAlert.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -14,9 +14,7 @@ import messages from './messages'; const UpgradeToCompleteAlert = ({ logUpgradeLinkClick }) => { const intl = useIntl(); - const { - courseId, - } = useSelector(state => state.courseHome); + const { courseId } = useParams(); const { datesBannerInfo, diff --git a/src/course-home/suggested-schedule-messaging/UpgradeToShiftDatesAlert.jsx b/src/course-home/suggested-schedule-messaging/UpgradeToShiftDatesAlert.jsx index 7878e469fb..dcec55cb5e 100644 --- a/src/course-home/suggested-schedule-messaging/UpgradeToShiftDatesAlert.jsx +++ b/src/course-home/suggested-schedule-messaging/UpgradeToShiftDatesAlert.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -15,9 +15,7 @@ import messages from './messages'; const UpgradeToShiftDatesAlert = ({ logUpgradeLinkClick, model }) => { const intl = useIntl(); - const { - courseId, - } = useSelector(state => state.courseHome); + const { courseId } = useParams(); const { datesBannerInfo, diff --git a/src/data/modelStoreBridge.ts b/src/data/modelStoreBridge.ts new file mode 100644 index 0000000000..4e5f89c3b7 --- /dev/null +++ b/src/data/modelStoreBridge.ts @@ -0,0 +1,21 @@ +import type { Query } from '@tanstack/react-query'; +import { Store } from 'redux'; + +import { addModel } from '@src/generic/model-store'; + +interface ModelStoreMeta { + modelType?: string; + courseId?: string; +} + +// Transitional (#1977): bridge a React Query result into the model store so existing +// `useModel(...)` readers (the shared TabPage/LoadedTabPage and not-yet-converted tabs) +// keep working until the model store is dissolved. A query opts in by tagging itself with +// `meta: { modelType, courseId }`. This is wired as the app QueryCache's `onSuccess` (see +// src/queryClient.ts), so it runs before observers re-render. +export const bridgeToModelStore = (store: Store, data: unknown, query: Query) => { + const { modelType, courseId } = (query.meta ?? {}) as ModelStoreMeta; + if (modelType) { + store.dispatch(addModel({ modelType, model: { id: courseId, ...(data as Record) } })); + } +}; diff --git a/src/index.jsx b/src/index.jsx index 75f7f0c1d2..fedd863781 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -4,7 +4,7 @@ import { getConfig, } from '@edx/frontend-platform'; import { AppProvider, ErrorPage, PageWrap } from '@edx/frontend-platform/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { Routes, Route } from 'react-router-dom'; @@ -26,9 +26,10 @@ import GoalUnsubscribe from './course-home/goal-unsubscribe'; import ProgressTab from './course-home/progress-tab/ProgressTab'; import { TabContainer } from './tab-page'; -import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data'; +import { fetchOutlineTab, fetchProgressTab } from './course-home/data'; import { fetchCourse } from './courseware/data'; import { store } from './store'; +import { createQueryClient } from './queryClient'; import NoticesProvider from './generic/notices'; import PathFixesProvider from './generic/path-fixes'; import { ToastProvider } from './generic/ToastContext'; @@ -39,7 +40,7 @@ import { DECODE_ROUTES, ROUTES } from './constants'; import PreferencesUnsubscribe from './preferences-unsubscribe'; import PageNotFound from './generic/PageNotFound'; -const queryClient = new QueryClient(); +const queryClient = createQueryClient(store); subscribe(APP_READY, () => { const root = createRoot(document.getElementById('root')); @@ -94,9 +95,7 @@ subscribe(APP_READY, () => { path={DECODE_ROUTES.DATES} element={( - - - + )} /> diff --git a/src/queryClient.test.ts b/src/queryClient.test.ts new file mode 100644 index 0000000000..757da0bbbc --- /dev/null +++ b/src/queryClient.test.ts @@ -0,0 +1,45 @@ +import { QueryClient } from '@tanstack/react-query'; +import { configureStore } from '@reduxjs/toolkit'; + +import { reducer as modelsReducer } from './generic/model-store'; +import { createAppQueryCache } from './queryClient'; +import { initializeMockApp } from './setupTest'; + +const { loggingService } = initializeMockApp(); + +const makeStore = () => configureStore({ reducer: { models: modelsReducer } }); + +describe('app query cache', () => { + let store: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + store = makeStore(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + queryCache: createAppQueryCache(store), + }); + loggingService.logError.mockReset(); + }); + + it('reports query errors through onError', async () => { + const error = new Error('boom'); + await queryClient.fetchQuery({ + queryKey: ['failing'], + queryFn: () => Promise.reject(error), + }).catch(() => {}); + + expect(loggingService.logError).toHaveBeenCalledWith(error, undefined); + }); + + it('bridges successful results into the model store through onSuccess', async () => { + await queryClient.fetchQuery({ + queryKey: ['ok'], + queryFn: () => Promise.resolve({ value: 42 }), + meta: { modelType: 'widget', courseId: 'course-1' }, + }); + + const models = store.getState().models as Record>; + expect(models.widget['course-1']).toEqual({ id: 'course-1', value: 42 }); + }); +}); diff --git a/src/queryClient.ts b/src/queryClient.ts new file mode 100644 index 0000000000..7c9a4eacab --- /dev/null +++ b/src/queryClient.ts @@ -0,0 +1,16 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { QueryCache, QueryClient } from '@tanstack/react-query'; +import { Store } from 'redux'; + +import { bridgeToModelStore } from './data/modelStoreBridge'; + +// `onSuccess` bridges results into the model store (transitional, #1977); the `store` param +// exists only to feed it and goes away when the bridge is removed. +export const createAppQueryCache = (store: Store) => new QueryCache({ + onSuccess: (data, query) => bridgeToModelStore(store, data, query), + onError: (error) => logError(error), +}); + +export const createQueryClient = (store: Store) => new QueryClient({ + queryCache: createAppQueryCache(store), +}); diff --git a/src/setupTest.js b/src/setupTest.js index 2459cb1753..fa950ea380 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -14,6 +14,7 @@ import MockAdapter from 'axios-mock-adapter'; import { reducer as specialExamsReducer } from '@edx/frontend-lib-special-exams'; import { AppProvider } from '@edx/frontend-platform/react'; import { reducer as courseHomeReducer } from './course-home/data'; +import { createAppQueryCache } from './queryClient'; import { reducer as coursewareReducer } from './courseware/data/slice'; import { reducer as modelsReducer } from './generic/model-store'; import { UserMessagesProvider } from './generic/user-messages'; @@ -243,12 +244,13 @@ export async function initializeTestStore(options = {}, overrideStore = true) { return store; } -export function createTestQueryClient() { +export function createTestQueryClient(store) { return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, + ...(store ? { queryCache: createAppQueryCache(store) } : {}), }); } diff --git a/src/tab-page/TabContainer.jsx b/src/tab-page/TabContainer.jsx index d69e62f40f..e0a45c9909 100644 --- a/src/tab-page/TabContainer.jsx +++ b/src/tab-page/TabContainer.jsx @@ -2,9 +2,8 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { OuterExamTimer } from '@edx/frontend-lib-special-exams'; -import TabPage from './TabPage'; +import TabWithTimer from './TabWithTimer'; const TabContainer = (props) => { const { @@ -36,15 +35,14 @@ const TabContainer = (props) => { } = useSelector(state => state[slice]); return ( - - {courseId && } {children} - + ); }; diff --git a/src/tab-page/TabPage.test.jsx b/src/tab-page/TabPage.test.jsx index 33b50f8beb..f4a72c8f68 100644 --- a/src/tab-page/TabPage.test.jsx +++ b/src/tab-page/TabPage.test.jsx @@ -4,6 +4,7 @@ import { } from '../setupTest'; import { TabPage } from './index'; import { useToast } from '../generic/ToastContext'; +import { addModel } from '../generic/model-store'; // We should not test `LoadedTabPage` page here, as `TabPage` is used only for passing `passthroughProps`. jest.mock('./LoadedTabPage', () => function () { @@ -96,4 +97,77 @@ describe('Tab Page', () => { render(, { wrapWithRouter: true }); expect(screen.getByTestId('LoadedTabPage')).toBeInTheDocument(); }); + + describe('React Query courseStatus', () => { + const metaWithAccess = { data: { courseAccess: { hasAccess: true } } }; + + it('renders the Loaded Tab Page when both queries resolve with access', () => { + render( + , + { wrapWithRouter: true }, + ); + expect(screen.getByTestId('LoadedTabPage')).toBeInTheDocument(); + }); + + it('displays loading while the metadata query is loading', () => { + render( + , + { wrapWithRouter: true }, + ); + expect(screen.getByText('Loading course page…')).toBeInTheDocument(); + }); + + it('displays loading while the tab-data query is loading', () => { + render( + , + { wrapWithRouter: true }, + ); + expect(screen.getByText('Loading course page…')).toBeInTheDocument(); + }); + + it('displays the error message when the metadata query fails', () => { + render( + , + { wrapWithRouter: true }, + ); + expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument(); + }); + + it('displays the error message when the tab-data query fails', () => { + render( + , + { wrapWithRouter: true }, + ); + expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument(); + }); + + it('renders no tab content when courseId is missing', () => { + render( + , + { wrapWithRouter: true }, + ); + expect(screen.queryByTestId('LoadedTabPage')).not.toBeInTheDocument(); + }); + + it('does not render tab content when access is denied', async () => { + const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); + testStore.dispatch(addModel({ + modelType: 'courseHomeMeta', + model: { id: 'test-course', courseAccess: { hasAccess: false } }, + })); + render( + , + { store: testStore, wrapWithRouter: true }, + ); + expect(screen.queryByTestId('LoadedTabPage')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/tab-page/TabPage.tsx b/src/tab-page/TabPage.tsx index ba16d4bfd0..ead0375972 100644 --- a/src/tab-page/TabPage.tsx +++ b/src/tab-page/TabPage.tsx @@ -2,6 +2,7 @@ import React, { type ReactNode } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useSelector } from 'react-redux'; import { Navigate } from 'react-router-dom'; +import type { UseQueryResult } from '@tanstack/react-query'; import { Toast } from '@openedx/paragon'; import { FooterSlot } from '@edx/frontend-component-footer'; @@ -21,15 +22,51 @@ import LoadedTabPage from './LoadedTabPage'; import LaunchCourseHomeTourButton from '../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton'; import { TourProvider } from '../product-tours/TourContext'; -interface TabPageProps { +// A converted tab hands TabPage its metadata + tab-data queries and lets TabPage derive +// the view; not-yet-converted (Redux) callers still pass a plain status string. The +// metadata query is typed to only the field this file reads, not the whole (untyped) shape. +export type CourseStatus = StatusValue | { + metadataQuery: UseQueryResult<{ courseAccess?: { hasAccess: boolean } }>; + tabDataQuery: UseQueryResult; +}; + +export interface TabPageProps { activeTabSlug: string; courseId?: string; - courseStatus: StatusValue; + courseStatus: CourseStatus; metadataModel: string; unitId?: string; children?: ReactNode; } +interface TabView { + isLoading: boolean; + isError: boolean; + isDenied: boolean; +} + +const deriveView = (courseStatus: CourseStatus): TabView => { + const view = { isLoading: false, isError: false, isDenied: false }; + + // Transitional: legacy Redux callers pass a resolved status string. This branch and the + // StatusValue union member go when courseware — the last string caller — converts. + if (typeof courseStatus === 'string') { + if (courseStatus === LOADING) { return { ...view, isLoading: true }; } + if (courseStatus === DENIED) { return { ...view, isDenied: true }; } + if (courseStatus === LOADED) { return view; } + return { ...view, isError: true }; + } + + // Access is read from the metadata query, resolved before tabData is considered. + const { metadataQuery, tabDataQuery } = courseStatus; + if (metadataQuery.isError) { return { ...view, isError: true }; } + if (metadataQuery.isPending) { return { ...view, isLoading: true }; } + if (!metadataQuery.data?.courseAccess?.hasAccess) { return { ...view, isDenied: true }; } + if (tabDataQuery.isError) { return { ...view, isError: true }; } + if (tabDataQuery.isPending) { return { ...view, isLoading: true }; } + return view; +}; + const TabPage = ({ activeTabSlug, courseId, @@ -55,52 +92,67 @@ const TabPage = ({ title, } = useModel('courseHomeMeta', courseId); - if (courseStatus === DENIED) { + const { isLoading, isError, isDenied } = deriveView(courseStatus); + + if (isDenied) { const redirectUrl = getAccessDeniedRedirectUrl(courseId, activeTabSlug, courseAccess, start); if (redirectUrl) { return (); } } + // The page renders once metadata resolves without error — loaded, or denied without a + // redirect (the outline tab shows the page to denied learners). + const shouldRenderContent = !isLoading && !isError; + + const renderToast = () => ( + + {toastContent?.message ?? ''} + + ); + + const renderTourButton = () => { + if (metadataModel !== 'courseHomeMeta') { return null; } + return (); + }; + + const renderLoading = () => ( + + ); + + const renderLoadedTabPage = () => { + if (!courseId) { return null; } + return ( + + {children} + + ); + }; + + const renderError = () => ( +

+ {errorMessage || intl.formatMessage(messages.failure)} +

+ ); + return ( - {(courseStatus === LOADED || courseStatus === DENIED) && ( - <> - - {toastContent?.message ?? ''} - - {metadataModel === 'courseHomeMeta' && ()} - - )} - + {shouldRenderContent && renderToast()} + {shouldRenderContent && renderTourButton()} - - {courseStatus === LOADING && ( - - )} - - {(courseStatus === LOADED || courseStatus === DENIED) && courseId && ( - - {children} - - )} - - {/* courseStatus 'failed' and any other unexpected course status. */} - {courseStatus !== LOADING && courseStatus !== LOADED && courseStatus !== DENIED && ( -

- {errorMessage || intl.formatMessage(messages.failure)} -

- )} + {isLoading && renderLoading()} + {shouldRenderContent && renderLoadedTabPage()} + {isError && renderError()}
); diff --git a/src/tab-page/TabWithTimer.tsx b/src/tab-page/TabWithTimer.tsx new file mode 100644 index 0000000000..8f2ea81992 --- /dev/null +++ b/src/tab-page/TabWithTimer.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { OuterExamTimer } from '@edx/frontend-lib-special-exams'; + +import TabPage, { type TabPageProps } from './TabPage'; + +const TabWithTimer = ({ courseId, children, ...rest }: TabPageProps) => ( + + {courseId && } + {children} + +); + +export default TabWithTimer; diff --git a/src/tab-page/index.js b/src/tab-page/index.js index 6388f74ce8..00454ea9c5 100644 --- a/src/tab-page/index.js +++ b/src/tab-page/index.js @@ -1,2 +1,3 @@ export { default as TabContainer } from './TabContainer'; export { default as TabPage } from './TabPage'; +export { default as TabWithTimer } from './TabWithTimer';