diff --git a/src/course-home/data/apiHooks.test.tsx b/src/course-home/data/apiHooks.test.tsx
index 1278d74fb9..3a157dd53d 100644
--- a/src/course-home/data/apiHooks.test.tsx
+++ b/src/course-home/data/apiHooks.test.tsx
@@ -6,7 +6,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '../../setupTest';
import { ToastProvider, useToast } from '../../generic/ToastContext';
-import { useResetDeadlines, usePostEvent, useRequestCert } from './apiHooks';
+import {
+ useOutlineTabData, useResetDeadlines, usePostEvent, useRequestCert, useDismissWelcomeMessage,
+ useSaveWeeklyLearningGoal,
+} from './apiHooks';
const { loggingService } = initializeMockApp();
@@ -142,4 +145,81 @@ describe('course-home apiHooks', () => {
await waitFor(() => expect(loggingService.logError).toHaveBeenCalled());
});
});
+
+ describe('useDismissWelcomeMessage', () => {
+ const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
+
+ it('POSTs to the dismiss url', async () => {
+ axiosMock.onPost(dismissUrl).reply(201);
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useDismissWelcomeMessage(), { wrapper });
+
+ await act(async () => { await result.current.mutateAsync({ courseId: 'course-1' }); });
+
+ expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
+ });
+
+ it('logs the error when the POST fails', async () => {
+ axiosMock.onPost(dismissUrl).reply(500);
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useDismissWelcomeMessage(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync({ courseId: 'course-1' }).catch(() => {});
+ });
+
+ await waitFor(() => expect(loggingService.logError).toHaveBeenCalled());
+ });
+ });
+
+ describe('useOutlineTabData', () => {
+ const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/course-1`;
+
+ it('resolves to an empty object on a 403 (access is handled via the metadata request)', async () => {
+ axiosMock.onGet(outlineUrl).reply(403, {});
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useOutlineTabData('course-1'), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual({});
+ });
+
+ it('surfaces the error on a non-403 failure', async () => {
+ axiosMock.onGet(outlineUrl).reply(500);
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useOutlineTabData('course-1'), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ });
+ });
+
+ describe('useSaveWeeklyLearningGoal', () => {
+ const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
+
+ it('POSTs the weekly learning goal', async () => {
+ axiosMock.onPost(goalUrl).reply(200, {});
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useSaveWeeklyLearningGoal(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync({ courseId: 'course-1', daysPerWeek: 3, subscribedToReminders: true });
+ });
+
+ expect(axiosMock.history.post[0].url).toEqual(goalUrl);
+ });
+
+ it('logs the error when the POST fails', async () => {
+ axiosMock.onPost(goalUrl).reply(500);
+ const { wrapper } = buildWrapper();
+ const { result } = renderHook(() => useSaveWeeklyLearningGoal(), { wrapper });
+
+ await act(async () => {
+ await result.current.mutateAsync(
+ { courseId: 'course-1', daysPerWeek: 3, subscribedToReminders: true },
+ ).catch(() => {});
+ });
+
+ await waitFor(() => expect(loggingService.logError).toHaveBeenCalled());
+ });
+ });
});
diff --git a/src/course-home/data/apiHooks.ts b/src/course-home/data/apiHooks.ts
index f5d4004ecd..ada7375297 100644
--- a/src/course-home/data/apiHooks.ts
+++ b/src/course-home/data/apiHooks.ts
@@ -3,7 +3,14 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { useToast, ToastContent } from '@src/generic/ToastContext';
import {
- executePostFromPostEvent, getCourseHomeCourseMetadata, getDatesTabData, postCourseDeadlines, postRequestCert,
+ executePostFromPostEvent,
+ getCourseHomeCourseMetadata,
+ getDatesTabData,
+ getOutlineTabData,
+ postCourseDeadlines,
+ postDismissWelcomeMessage,
+ postRequestCert,
+ postWeeklyLearningGoal,
} from './api';
import { courseHomeQueryKeys } from './queryKeys';
@@ -61,7 +68,25 @@ export const useDatesTabData = (courseId: string) => useQuery({
meta: { modelType: 'dates', courseId },
});
+export const useOutlineTabData = (courseId: string) => useQuery({
+ queryKey: courseHomeQueryKeys.outlineTab(courseId),
+ queryFn: () => getOutlineTabData(courseId),
+ meta: { modelType: 'outline', courseId },
+});
+
export const useRequestCert = () => useMutation({
mutationFn: ({ courseId }: { courseId: string }) => postRequestCert(courseId),
onError: (error) => logError(error),
});
+
+export const useDismissWelcomeMessage = () => useMutation({
+ mutationFn: ({ courseId }: { courseId: string }) => postDismissWelcomeMessage(courseId),
+ onError: (error) => logError(error),
+});
+
+export const useSaveWeeklyLearningGoal = () => useMutation({
+ mutationFn: ({ courseId, daysPerWeek, subscribedToReminders }: {
+ courseId: string; daysPerWeek: number; subscribedToReminders: boolean;
+ }) => postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders),
+ onError: (error) => logError(error),
+});
diff --git a/src/course-home/data/index.js b/src/course-home/data/index.js
index 7ac41d169c..14b7826f01 100644
--- a/src/course-home/data/index.js
+++ b/src/course-home/data/index.js
@@ -1,8 +1,6 @@
export {
- fetchOutlineTab,
fetchProgressTab,
deprecatedSaveCourseGoal,
- saveWeeklyLearningGoal,
} from './thunks';
export { reducer } from './slice';
diff --git a/src/course-home/data/queryKeys.ts b/src/course-home/data/queryKeys.ts
index f8f97e53d3..15dfcd794e 100644
--- a/src/course-home/data/queryKeys.ts
+++ b/src/course-home/data/queryKeys.ts
@@ -4,4 +4,5 @@ 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,
+ outlineTab: (courseId: string) => [...courseHomeQueryKeys.all, 'outlineTab', courseId] as const,
};
diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js
index 1743b3e517..453faf9f02 100644
--- a/src/course-home/data/redux.test.js
+++ b/src/course-home/data/redux.test.js
@@ -42,45 +42,6 @@ describe('Data layer integration tests', () => {
store = initializeStore();
});
- describe('Test fetchOutlineTab', () => {
- const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
- const outlineUrl = `${outlineBaseUrl}/${courseId}`;
-
- it('Should result in fetch failure if error occurs', async () => {
- axiosMock.onGet(courseMetadataUrl).networkError();
- axiosMock.onGet(outlineUrl).networkError();
-
- await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
-
- expect(loggingService.logError).toHaveBeenCalled();
- expect(store.getState().courseHome.courseStatus).toEqual('failed');
- });
-
- it('Should fetch, normalize, and save metadata', async () => {
- const outlineTabData = Factory.build('outlineTabData', { courseId });
-
- axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
- axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
-
- await executeThunk(thunks.fetchOutlineTab(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 outline API status',
- async (errorStatus) => {
- axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
- axiosMock.onGet(outlineUrl).reply(errorStatus, {});
-
- await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
-
- expect(store.getState().courseHome.courseStatus).toEqual('denied');
- },
- );
- });
-
describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
@@ -148,18 +109,6 @@ describe('Data layer integration tests', () => {
});
});
- describe('Test dismissWelcomeMessage', () => {
- it('Should dismiss welcome message', async () => {
- const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
- axiosMock.onPost(dismissUrl).reply(201);
-
- await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);
-
- expect(axiosMock.history.post[0].url).toEqual(dismissUrl);
- expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
- });
- });
-
describe('Test fetchExamAttemptsData', () => {
const sequenceIds = [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
diff --git a/src/course-home/data/thunks.js b/src/course-home/data/thunks.js
index 300d2bac51..dc1a37c446 100644
--- a/src/course-home/data/thunks.js
+++ b/src/course-home/data/thunks.js
@@ -2,11 +2,8 @@ import { logError } from '@edx/frontend-platform/logging';
import {
getCourseHomeCourseMetadata,
getExamsData,
- getOutlineTabData,
getProgressTabData,
deprecatedPostCourseGoals,
- postWeeklyLearningGoal,
- postDismissWelcomeMessage,
getLiveTabIframe,
} from './api';
@@ -88,10 +85,6 @@ export function fetchProgressTab(courseId, targetUserId) {
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
}
-export function fetchOutlineTab(courseId) {
- return fetchTab(courseId, 'outline', getOutlineTabData);
-}
-
export function fetchLiveTab(courseId) {
return fetchTab(courseId, 'live', getLiveTabIframe);
}
@@ -100,18 +93,10 @@ export function fetchDiscussionTab(courseId) {
return fetchTab(courseId, 'discussion');
}
-export function dismissWelcomeMessage(courseId) {
- return async () => postDismissWelcomeMessage(courseId);
-}
-
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
return deprecatedPostCourseGoals(courseId, goalKey);
}
-export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
- return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
-}
-
export function fetchExamAttemptsData(courseId, sequenceIds) {
return async (dispatch) => {
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
diff --git a/src/course-home/outline-tab/DateSummary.jsx b/src/course-home/outline-tab/DateSummary.jsx
index d1e5cbc7d8..f634fbbe08 100644
--- a/src/course-home/outline-tab/DateSummary.jsx
+++ b/src/course-home/outline-tab/DateSummary.jsx
@@ -3,7 +3,7 @@ import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { FormattedDate } from '@edx/frontend-platform/i18n';
import React from 'react';
-import { useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useModel } from '../../generic/model-store';
import { isLearnerAssignment } from '../dates-tab/utils';
@@ -13,9 +13,7 @@ const DateSummary = ({
dateBlock,
userTimezone,
}) => {
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const {
org,
} = useModel('courseHomeMeta', courseId);
diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx
index db33f55fc5..fb38ec57e6 100644
--- a/src/course-home/outline-tab/OutlineTab.jsx
+++ b/src/course-home/outline-tab/OutlineTab.jsx
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
@@ -13,7 +13,7 @@ import CourseHandouts from './widgets/CourseHandouts';
import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard';
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools';
-import { fetchOutlineTab } from '../data';
+import { useCourseHomeMeta, useOutlineTabData } from '../data/apiHooks';
import messages from './messages';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
@@ -27,13 +27,12 @@ import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
import CourseHomeSectionOutlineSlot from '../../plugin-slots/CourseHomeSectionOutlineSlot';
+import { TabWithTimer } from '../../tab-page';
-const OutlineTab = () => {
+const OutlineTabContent = () => {
const intl = useIntl();
- const {
- courseId,
- proctoringPanelStatus,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
+ const { proctoringPanelStatus } = useSelector(state => state.courseHome);
const {
isSelfPaced,
@@ -146,7 +145,7 @@ const OutlineTab = () => {
/>
{isSelfPaced && hasDeadlines && (
<>
-
+
>
)}
@@ -191,4 +190,20 @@ const OutlineTab = () => {
);
};
+const OutlineTab = () => {
+ const { courseId } = useParams();
+ const metadataQuery = useCourseHomeMeta(courseId);
+ const tabDataQuery = useOutlineTabData(courseId);
+
+ return (
+
+
+
+ );
+};
+
export default OutlineTab;
diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx
index 8613916368..df4faa74cb 100644
--- a/src/course-home/outline-tab/OutlineTab.test.jsx
+++ b/src/course-home/outline-tab/OutlineTab.test.jsx
@@ -5,24 +5,26 @@ import React from 'react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
import userEvent from '@testing-library/user-event';
+import { render } from '@testing-library/react';
import messages from './messages';
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
import {
- fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, act,
+ createTestQueryClient, fireEvent, initializeMockApp, logUnhandledRequests, screen, waitFor, act,
} from '../../setupTest';
-import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
-import * as thunks from '../data/thunks';
+import { appendBrowserTimezoneToUrl } from '../../utils';
import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab';
-import LoadedTabPage from '../../tab-page/LoadedTabPage';
-import { TourProvider } from '../../product-tours/TourContext';
+import { UserMessagesProvider } from '../../generic/user-messages';
+import { ToastProvider } from '../../generic/ToastContext';
const mockCoursewareSearchParams = jest.fn();
@@ -71,19 +73,26 @@ describe('Outline Tab', () => {
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
}
- async function fetchAndRender(path = '') {
- await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
+ async function fetchAndRender(path = '', { renderStore = store, waitForLoaded = true } = {}) {
const search = path.includes('?') ? path.slice(path.indexOf('?')) : '';
await act(async () => render(
-
-
-
- } />
-
-
- ,
- { store },
+
+
+
+
+
+
+ } />
+
+
+
+
+
+ ,
));
+ if (waitForLoaded) {
+ await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
+ }
}
beforeEach(async () => {
@@ -111,6 +120,23 @@ describe('Outline Tab', () => {
jest.clearAllMocks();
});
+ describe('Access denied', () => {
+ it('waits for the outline data before rendering for a denied learner (no crash)', async () => {
+ const testStore = initializeStore();
+ setMetadata({ course_access: { has_access: false, error_code: 'authentication_required' }, is_enrolled: false });
+ let resolveOutline;
+ axiosMock.onGet(outlineUrl).reply(() => new Promise((resolve) => { resolveOutline = resolve; }));
+
+ await fetchAndRender('', { renderStore: testStore, waitForLoaded: false });
+
+ await waitFor(() => expect(testStore.getState().models.courseHomeMeta?.[courseId]).toBeDefined());
+ expect(screen.getByRole('status')).toBeInTheDocument();
+
+ await act(async () => { resolveOutline([200, Factory.build('outlineTabData')]); });
+ expect(await screen.findByTestId('private-course-alert')).toBeInTheDocument();
+ });
+ });
+
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
@@ -429,12 +455,10 @@ describe('Outline Tab', () => {
weekly_learning_goal_enabled: true,
},
});
- const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
-
await fetchAndRender();
- const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
+ const button = screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
- expect(spy).toHaveBeenCalledTimes(0);
+ expect(axiosMock.history.post.some(req => req.url.includes('save_course_goal'))).toBe(false);
});
it('post goal via query param', async () => {
@@ -443,11 +467,12 @@ describe('Outline Tab', () => {
weekly_learning_goal_enabled: true,
},
});
- const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?weekly_goal=3');
- expect(spy).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(
+ axiosMock.history.post.filter(req => req.url.includes('save_course_goal')),
+ ).toHaveLength(1));
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.setgoal', {});
});
@@ -665,9 +690,8 @@ describe('Outline Tab', () => {
masquerading_expired_course: true,
},
});
- await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
- await act(async () => render(..., { store }));
- const instructorToolbar = await screen.getByTestId('instructor-toolbar');
+ await fetchAndRender();
+ const instructorToolbar = screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
@@ -681,9 +705,8 @@ describe('Outline Tab', () => {
masquerading_expired_course: false,
},
});
- await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
- await act(async () => render(..., { store }));
- const instructorToolbar = await screen.getByTestId('instructor-toolbar');
+ await fetchAndRender();
+ const instructorToolbar = screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
});
diff --git a/src/course-home/outline-tab/section-outline/Section.tsx b/src/course-home/outline-tab/section-outline/Section.tsx
index f905294055..43314cd3bc 100644
--- a/src/course-home/outline-tab/section-outline/Section.tsx
+++ b/src/course-home/outline-tab/section-outline/Section.tsx
@@ -3,9 +3,9 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton } from '@openedx/paragon';
import { Minus, Plus } from '@openedx/paragon/icons';
+import { useParams } from 'react-router-dom';
import { useModel } from '../../../generic/model-store';
import genericMessages from '../../../generic/messages';
-import { useContextId } from '../../../data/hooks';
import messages from '../messages';
import SectionTitle from './SectionTitle';
import SequenceLink from './SequenceLink';
@@ -27,7 +27,7 @@ const Section: React.FC = ({
section,
}) => {
const intl = useIntl();
- const courseId = useContextId();
+ const { courseId } = useParams();
const {
complete,
sequenceIds,
diff --git a/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx b/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx
index 9c25379240..9d07d29cec 100644
--- a/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx
+++ b/src/course-home/outline-tab/section-outline/SequenceDueDate.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import { FormattedTime, useIntl } from '@edx/frontend-platform/i18n';
+import { useParams } from 'react-router-dom';
import { useModel } from '../../../generic/model-store';
-import { useContextId } from '../../../data/hooks';
import messages from '../messages';
interface Props {
@@ -17,7 +17,7 @@ const SequenceDueDate: React.FC = ({
description,
}) => {
const intl = useIntl();
- const courseId = useContextId();
+ const { courseId } = useParams();
let dueDateMessage: string | React.ReactNode = intl.formatMessage(
messages.sequenceNoDueDate,
{ description: description || '' },
diff --git a/src/course-home/outline-tab/section-outline/SequenceTitle.tsx b/src/course-home/outline-tab/section-outline/SequenceTitle.tsx
index ec035dfa28..3e49c6a1f7 100644
--- a/src/course-home/outline-tab/section-outline/SequenceTitle.tsx
+++ b/src/course-home/outline-tab/section-outline/SequenceTitle.tsx
@@ -1,12 +1,11 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { Link } from 'react-router-dom';
+import { Link, useParams } from 'react-router-dom';
import { Icon } from '@openedx/paragon';
import { CheckCircleOutline, CheckCircle } from '@openedx/paragon/icons';
import EffortEstimate from '../../../shared/effort-estimate';
import messages from '../messages';
-import { useContextId } from '../../../data/hooks';
interface Props {
complete: boolean;
@@ -24,7 +23,7 @@ const SequenceTitle: React.FC = ({
id,
}) => {
const intl = useIntl();
- const courseId = useContextId();
+ const { courseId } = useParams();
const coursewareUrl = {title};
const displayTitle = showLink ? coursewareUrl : title;
diff --git a/src/course-home/outline-tab/widgets/CourseDates.jsx b/src/course-home/outline-tab/widgets/CourseDates.jsx
index fa28a6433a..a1fe22da01 100644
--- a/src/course-home/outline-tab/widgets/CourseDates.jsx
+++ b/src/course-home/outline-tab/widgets/CourseDates.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -9,9 +9,7 @@ import { useModel } from '../../../generic/model-store';
const CourseDates = () => {
const intl = useIntl();
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
diff --git a/src/course-home/outline-tab/widgets/CourseHandouts.jsx b/src/course-home/outline-tab/widgets/CourseHandouts.jsx
index 8c609531b9..522d0465bd 100644
--- a/src/course-home/outline-tab/widgets/CourseHandouts.jsx
+++ b/src/course-home/outline-tab/widgets/CourseHandouts.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -9,9 +9,7 @@ import { useModel } from '../../../generic/model-store';
const CourseHandouts = () => {
const intl = useIntl();
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const {
handoutsHtml,
} = useModel('outline', courseId);
diff --git a/src/course-home/outline-tab/widgets/CourseTools.jsx b/src/course-home/outline-tab/widgets/CourseTools.jsx
index a3a7e13944..a50bb849ff 100644
--- a/src/course-home/outline-tab/widgets/CourseTools.jsx
+++ b/src/course-home/outline-tab/widgets/CourseTools.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
@@ -16,9 +16,7 @@ import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHome
const CourseTools = () => {
const intl = useIntl();
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const { org } = useModel('courseHomeMeta', courseId);
const {
courseTools,
diff --git a/src/course-home/outline-tab/widgets/ProctoringInfoPanel.jsx b/src/course-home/outline-tab/widgets/ProctoringInfoPanel.jsx
index 2872f80507..b7d9baf84b 100644
--- a/src/course-home/outline-tab/widgets/ProctoringInfoPanel.jsx
+++ b/src/course-home/outline-tab/widgets/ProctoringInfoPanel.jsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch } from 'react-redux';
+import { useParams } from 'react-router-dom';
import camelCase from 'lodash.camelcase';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -13,9 +14,7 @@ import { useModel } from '../../../generic/model-store';
const ProctoringInfoPanel = () => {
const intl = useIntl();
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const {
username,
} = useModel('courseHomeMeta', courseId);
diff --git a/src/course-home/outline-tab/widgets/StartOrResumeCourseCard.jsx b/src/course-home/outline-tab/widgets/StartOrResumeCourseCard.jsx
index a75e76be3e..641c1de66b 100644
--- a/src/course-home/outline-tab/widgets/StartOrResumeCourseCard.jsx
+++ b/src/course-home/outline-tab/widgets/StartOrResumeCourseCard.jsx
@@ -2,16 +2,14 @@ import React from 'react';
import { Button, Card } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const StartOrResumeCourseCard = () => {
const intl = useIntl();
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const {
org,
diff --git a/src/course-home/outline-tab/widgets/WeeklyLearningGoalCard.jsx b/src/course-home/outline-tab/widgets/WeeklyLearningGoalCard.jsx
index 575cb8ad67..b9a29a4d13 100644
--- a/src/course-home/outline-tab/widgets/WeeklyLearningGoalCard.jsx
+++ b/src/course-home/outline-tab/widgets/WeeklyLearningGoalCard.jsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
-import { useLocation } from 'react-router-dom';
+import { useLocation, useParams } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@openedx/paragon';
@@ -8,10 +8,9 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Email } from '@openedx/paragon/icons';
-import { useSelector } from 'react-redux';
import messages from '../messages';
import LearningGoalButton from './LearningGoalButton';
-import { saveWeeklyLearningGoal } from '../../data';
+import { useSaveWeeklyLearningGoal } from '../../data/apiHooks';
import { useModel } from '../../../generic/model-store';
import './FlagButton.scss';
@@ -20,9 +19,7 @@ const WeeklyLearningGoalCard = ({
subscribedToReminders,
}) => {
const intl = useIntl();
- const {
- courseId,
- } = useSelector(state => state.courseHome);
+ const { courseId } = useParams();
const {
isMasquerading,
@@ -30,6 +27,7 @@ const WeeklyLearningGoalCard = ({
} = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
+ const saveWeeklyLearningGoal = useSaveWeeklyLearningGoal();
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types
@@ -42,7 +40,7 @@ const WeeklyLearningGoalCard = ({
setGetReminderSelected(selectReminders);
setDaysPerWeekGoal(days);
if (!isMasquerading) { // don't save goal updates while masquerading
- saveWeeklyLearningGoal(courseId, days, selectReminders);
+ saveWeeklyLearningGoal.mutate({ courseId, daysPerWeek: days, subscribedToReminders: selectReminders });
sendTrackEvent('edx.ui.lms.goal.days-per-week.changed', {
org_key: org,
courserun_key: courseId,
@@ -60,7 +58,9 @@ const WeeklyLearningGoalCard = ({
const isGetReminderChecked = event.target.checked;
setGetReminderSelected(isGetReminderChecked);
if (!isMasquerading) { // don't save goal updates while masquerading
- saveWeeklyLearningGoal(courseId, daysPerWeekGoal, isGetReminderChecked);
+ saveWeeklyLearningGoal.mutate({
+ courseId, daysPerWeek: daysPerWeekGoal, subscribedToReminders: isGetReminderChecked,
+ });
sendTrackEvent('edx.ui.lms.goal.reminder-selected.changed', {
org_key: org,
courserun_key: courseId,
diff --git a/src/course-home/outline-tab/widgets/WelcomeMessage.jsx b/src/course-home/outline-tab/widgets/WelcomeMessage.jsx
index 40bac852e7..0bafc72e19 100644
--- a/src/course-home/outline-tab/widgets/WelcomeMessage.jsx
+++ b/src/course-home/outline-tab/widgets/WelcomeMessage.jsx
@@ -5,11 +5,10 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@openedx/paragon';
import truncate from 'truncate-html';
-import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
-import { dismissWelcomeMessage } from '../../data/thunks';
+import { useDismissWelcomeMessage } from '../../data/apiHooks';
const WelcomeMessage = ({ courseId, nextElementRef }) => {
const intl = useIntl();
@@ -37,7 +36,7 @@ const WelcomeMessage = ({ courseId, nextElementRef }) => {
);
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
- const dispatch = useDispatch();
+ const dismissWelcomeMessage = useDismissWelcomeMessage();
if (!welcomeMessageHtml) {
return null;
@@ -53,7 +52,7 @@ const WelcomeMessage = ({ courseId, nextElementRef }) => {
onClose={() => {
nextElementRef.current?.focus();
setDisplay(false);
- dispatch(dismissWelcomeMessage(courseId));
+ dismissWelcomeMessage.mutate({ courseId });
}}
className="raised-card"
actions={messageCanBeShortened ? [
diff --git a/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx b/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx
index b1c82d1f0f..2eea824aac 100644
--- a/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx
+++ b/src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx
@@ -1,5 +1,4 @@
import React from 'react';
-import { useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
@@ -17,11 +16,10 @@ import { courseHomeQueryKeys } from '../data/queryKeys';
import { useModel } from '../../generic/model-store';
import messages from './messages';
-const ShiftDatesAlert = ({ fetch, model }) => {
+const ShiftDatesAlert = ({ model }) => {
const intl = useIntl();
const { courseId } = useParams();
const queryClient = useQueryClient();
- const dispatch = useDispatch();
const {
datesBannerInfo,
@@ -41,9 +39,7 @@ const ShiftDatesAlert = ({ fetch, model }) => {
const refreshTabData = () => {
queryClient.invalidateQueries({ queryKey: courseHomeQueryKeys.datesTab(courseId) });
- if (fetch) {
- dispatch(fetch(courseId));
- }
+ queryClient.invalidateQueries({ queryKey: courseHomeQueryKeys.outlineTab(courseId) });
};
return (
@@ -72,12 +68,7 @@ const ShiftDatesAlert = ({ fetch, model }) => {
};
ShiftDatesAlert.propTypes = {
- fetch: PropTypes.func,
model: PropTypes.string.isRequired,
};
-ShiftDatesAlert.defaultProps = {
- fetch: undefined,
-};
-
export default ShiftDatesAlert;
diff --git a/src/index.jsx b/src/index.jsx
index fedd863781..5b7d6ba358 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -26,7 +26,7 @@ import GoalUnsubscribe from './course-home/goal-unsubscribe';
import ProgressTab from './course-home/progress-tab/ProgressTab';
import { TabContainer } from './tab-page';
-import { fetchOutlineTab, fetchProgressTab } from './course-home/data';
+import { fetchProgressTab } from './course-home/data';
import { fetchCourse } from './courseware/data';
import { store } from './store';
import { createQueryClient } from './queryClient';
@@ -75,9 +75,7 @@ subscribe(APP_READY, () => {
path={DECODE_ROUTES.HOME}
element={(
-
-
-
+
)}
/>
diff --git a/src/product-tours/ProductTours.test.jsx b/src/product-tours/ProductTours.test.jsx
index a5b87340a0..8024512916 100644
--- a/src/product-tours/ProductTours.test.jsx
+++ b/src/product-tours/ProductTours.test.jsx
@@ -3,29 +3,28 @@
* @jest-environment jsdom
*/
import React from 'react';
-import { Route, Routes } from 'react-router-dom';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
+import { QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { waitForElementToBeRemoved } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import * as popper from '@popperjs/core';
import {
- fireEvent, initializeMockApp, logUnhandledRequests, render, screen,
+ act, createTestQueryClient, fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor,
} from '../setupTest';
import initializeStore from '../store';
-import { appendBrowserTimezoneToUrl, executeThunk } from '../utils';
+import { appendBrowserTimezoneToUrl } from '../utils';
import CoursewareContainer from '../courseware/CoursewareContainer';
-import LoadedTabPage from '../tab-page/LoadedTabPage';
import { TourProvider } from './TourContext';
import ProductTours from './ProductTours';
import OutlineTab from '../course-home/outline-tab/OutlineTab';
-import * as courseHomeThunks from '../course-home/data/thunks';
import { buildSimpleCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
import { buildOutlineFromBlocks } from '../courseware/data/__factories__/learningSequencesOutline.factory';
@@ -62,15 +61,17 @@ describe('Course Home Tours', () => {
}
async function fetchAndRender() {
- await executeThunk(courseHomeThunks.fetchOutlineTab(courseId), store.dispatch);
- render(
-
-
-
-
- ,
- { store, wrapWithRouter: true },
- );
+ await act(async () => render(
+
+
+
+ } />
+
+
+ ,
+ { store },
+ ));
+ await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
}
beforeEach(async () => {
@@ -193,7 +194,8 @@ describe('Course Home Tours', () => {
});
it('launches tour on button click', async () => {
- const launchTourButton = await screen.findByRole('button', { name: 'Launch tour' });
+ const buttons = await screen.findAllByRole('button', { name: 'Launch tour' });
+ const launchTourButton = buttons.find((button) => !button.classList.contains('sr-only'));
expect(launchTourButton).toBeInTheDocument();
fireEvent.click(launchTourButton);
diff --git a/src/tab-page/TabPage.test.jsx b/src/tab-page/TabPage.test.jsx
index 049e226694..d56d897ef9 100644
--- a/src/tab-page/TabPage.test.jsx
+++ b/src/tab-page/TabPage.test.jsx
@@ -167,6 +167,22 @@ describe('Tab Page', () => {
expect(screen.queryByTestId('LoadedTabPage')).not.toBeInTheDocument();
});
+ it('shows loading when access is denied but the tab-data query is still pending', () => {
+ render(
+ ,
+ { wrapWithRouter: true },
+ );
+ expect(screen.getByText('Loading course pageā¦')).toBeInTheDocument();
+ 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({
diff --git a/src/tab-page/TabPage.tsx b/src/tab-page/TabPage.tsx
index 22064f3a13..d3689e0d95 100644
--- a/src/tab-page/TabPage.tsx
+++ b/src/tab-page/TabPage.tsx
@@ -60,9 +60,9 @@ const deriveView = (courseStatus: CourseStatus): TabView => {
const { metadataQuery, tabDataQuery } = courseStatus;
if (metadataQuery.isError) { return { ...view, isError: true }; }
if (metadataQuery.isPending) { return { ...view, isLoading: true }; }
+ if (tabDataQuery.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;
};