Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/course-home/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Factory } from 'rosie';
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 {
useOutlineTabData, useResetDeadlines, usePostEvent, useRequestCert, useDismissWelcomeMessage,
useSaveWeeklyLearningGoal,
useOutlineTabData, useProgressTabData, useResetDeadlines, usePostEvent, useRequestCert,
useDismissWelcomeMessage, useSaveWeeklyLearningGoal,
} from './apiHooks';

const { loggingService } = initializeMockApp();
Expand Down Expand Up @@ -193,6 +194,68 @@ describe('course-home apiHooks', () => {
});
});

describe('useProgressTabData', () => {
const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress/course-1`;

it('transforms the server response', async () => {
axiosMock.onGet(progressUrl).reply(200, Factory.build('progressTabData'));
const { wrapper } = buildWrapper();
const { result } = renderHook(() => useProgressTabData('course-1'), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data.studioUrl).toEqual('http://studio.edx.org/settings/grading/course-v1:edX+Test+run');
expect(result.current.data.gradesFeatureIsFullyLocked).toBe(false);
});

it('appends the targetUserId to the request URL', async () => {
axiosMock.onGet(`${progressUrl}/7/`).reply(200, Factory.build('progressTabData'));
const { wrapper } = buildWrapper();
const { result } = renderHook(() => useProgressTabData('course-1', '7'), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(axiosMock.history.get[0].url).toEqual(`${progressUrl}/7/`);
});

it.each([401, 403])(
'resolves to an empty object on a %s (access is handled via the metadata request)',
async (status) => {
axiosMock.onGet(progressUrl).reply(status, {});
const { wrapper } = buildWrapper();
const { result } = renderHook(() => useProgressTabData('course-1'), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({});
},
);

it('redirects to the legacy progress page and resolves to an empty object on a 404', async () => {
// jsdom's location.replace is non-configurable, so we swap the whole location for the
// duration of this test (restored in finally so it can never bleed into another test).
const originalLocation = window.location;
const replace = jest.fn();
Object.defineProperty(window, 'location', { configurable: true, value: { replace } });
try {
axiosMock.onGet(progressUrl).reply(404, {});
const { wrapper } = buildWrapper();
const { result } = renderHook(() => useProgressTabData('course-1'), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({});
expect(replace).toHaveBeenCalledWith(`${getConfig().LMS_BASE_URL}/courses/course-1/progress`);
} finally {
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
}
});

it('surfaces the error on a non-handled failure', async () => {
axiosMock.onGet(progressUrl).reply(500);
const { wrapper } = buildWrapper();
const { result } = renderHook(() => useProgressTabData('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`;

Expand Down
7 changes: 7 additions & 0 deletions src/course-home/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getCourseHomeCourseMetadata,
getDatesTabData,
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
postDismissWelcomeMessage,
postRequestCert,
Expand Down Expand Up @@ -74,6 +75,12 @@ export const useOutlineTabData = (courseId: string) => useQuery({
meta: { modelType: 'outline', courseId },
});

export const useProgressTabData = (courseId: string, targetUserId?: string) => useQuery({
queryKey: courseHomeQueryKeys.progressTab(courseId, targetUserId),
queryFn: () => getProgressTabData(courseId, targetUserId),
meta: { modelType: 'progress', courseId },
});

export const useRequestCert = () => useMutation({
mutationFn: ({ courseId }: { courseId: string }) => postRequestCert(courseId),
onError: (error) => logError(error),
Expand Down
5 changes: 1 addition & 4 deletions src/course-home/data/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
export {
fetchProgressTab,
deprecatedSaveCourseGoal,
} from './thunks';
export { deprecatedSaveCourseGoal } from './thunks';

export { reducer } from './slice';
1 change: 1 addition & 0 deletions src/course-home/data/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const courseHomeQueryKeys = {
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,
progressTab: (courseId: string, targetUserId?: string) => [...courseHomeQueryKeys.all, 'progressTab', courseId, targetUserId] as const,
};
96 changes: 41 additions & 55 deletions src/course-home/data/redux.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,61 +42,6 @@ describe('Data layer integration tests', () => {
store = initializeStore();
});

describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;

it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${progressBaseUrl}/${courseId}`).networkError();

await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);

expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});

it('Should fetch, normalize, and save metadata', async () => {
const progressTabData = Factory.build('progressTabData', { courseId });

const progressUrl = `${progressBaseUrl}/${courseId}`;

axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(progressUrl).reply(200, progressTabData);

await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);

const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
});

it('Should handle the url including a targetUserId', async () => {
const progressTabData = Factory.build('progressTabData', { courseId });
const targetUserId = 2;
const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`;

axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(progressUrl).reply(200, progressTabData);

await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch);

const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2);
});

it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(progressUrl).reply(errorStatus, {});

await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);

expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
});

describe('Test saveCourseGoal', () => {
it('Should save course goal', async () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
Expand Down Expand Up @@ -282,4 +227,45 @@ describe('Data layer integration tests', () => {
expect(loggingService.logError).toHaveBeenCalled();
});
});

describe('Test fetchTab', () => {
const liveUrl = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;

it('Should fetch metadata + tab data and mark the tab loaded', async () => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(liveUrl).reply(200, { iframe: 'https://example.com/live' });

await executeThunk(thunks.fetchLiveTab(courseId), store.dispatch);

expect(store.getState().courseHome.courseStatus).toEqual('loaded');
});

it('Should result in denied when the learner lacks course access', async () => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(liveUrl).reply(200, {});

await executeThunk(thunks.fetchLiveTab(courseId), store.dispatch);

expect(store.getState().courseHome.courseStatus).toEqual('denied');
});

it('Should result in failure when the metadata request errors', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(liveUrl).reply(200, {});

await executeThunk(thunks.fetchLiveTab(courseId), store.dispatch);

expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});

it('Should result in failure when the tab data request errors', async () => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(liveUrl).reply(500);

await executeThunk(thunks.fetchLiveTab(courseId), store.dispatch);

expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
});
});
5 changes: 0 additions & 5 deletions src/course-home/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { logError } from '@edx/frontend-platform/logging';
import {
getCourseHomeCourseMetadata,
getExamsData,
getProgressTabData,
deprecatedPostCourseGoals,
getLiveTabIframe,
} from './api';
Expand Down Expand Up @@ -81,10 +80,6 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
};
}

export function fetchProgressTab(courseId, targetUserId) {
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
}

export function fetchLiveTab(courseId) {
return fetchTab(courseId, 'live', getLiveTabIframe);
}
Expand Down
13 changes: 5 additions & 8 deletions src/course-home/progress-tab/ProgressHeader.jsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useSelector } from 'react-redux';

import { useModel } from '../../generic/model-store';
import { useParams } from 'react-router-dom';

import { useProgressData } from './hooks';
import messages from './messages';

const ProgressHeader = () => {
const intl = useIntl();
const {
courseId,
targetUserId,
} = useSelector(state => state.courseHome);
const { targetUserId: targetUserIdParam } = useParams();
const targetUserId = parseInt(targetUserIdParam, 10);

const { administrator, userId } = getAuthenticatedUser();

const { studioUrl, username } = useModel('progress', courseId);
const { studioUrl, username } = useProgressData();

const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId);

Expand Down
30 changes: 24 additions & 6 deletions src/course-home/progress-tab/ProgressTab.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { useWindowSize } from '@openedx/paragon';
import { useContextId } from '../../data/hooks';
import { useModel } from '../../generic/model-store';
import { useParams } from 'react-router-dom';

import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';

import CourseCompletion from './course-completion/CourseCompletion';
Expand All @@ -11,11 +11,13 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
import { useGetExamsData } from './hooks';
import { useGetExamsData, useProgressData } from './hooks';
import { useCourseHomeMeta, useProgressTabData } from '../data/apiHooks';
import { TabWithTimer } from '../../tab-page';

const ProgressTab = () => {
const courseId = useContextId();
const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
const ProgressTabContent = () => {
const { courseId } = useParams();
const { disableProgressGraph, sectionScores } = useProgressData();

const sequenceIds = useMemo(() => (
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
Expand Down Expand Up @@ -53,4 +55,20 @@ const ProgressTab = () => {
);
};

const ProgressTab = () => {
const { courseId, targetUserId } = useParams();
const metadataQuery = useCourseHomeMeta(courseId);
const tabDataQuery = useProgressTabData(courseId, targetUserId);

return (
<TabWithTimer
activeTabSlug="progress"
courseId={courseId}
courseStatus={{ metadataQuery, tabDataQuery }}
>
<ProgressTabContent />
</TabWithTimer>
);
};

export default ProgressTab;
Loading