>;
+ 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';