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
31 changes: 30 additions & 1 deletion src/course-home/data/api.test.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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();
});
});
19 changes: 17 additions & 2 deletions src/course-home/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 },
});
1 change: 0 additions & 1 deletion src/course-home/data/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export {
fetchDatesTab,
fetchOutlineTab,
fetchProgressTab,
deprecatedSaveCourseGoal,
Expand Down
23 changes: 23 additions & 0 deletions src/course-home/data/modelStoreBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { QueryCache } from '@tanstack/react-query';
import { Store } from 'redux';

import { addModel } from '@src/generic/model-store';

interface ModelStoreMeta {
modelType?: string;
courseId?: string;
}

// Transitional (#1977): mirror React Query results 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 runs on the cache's success — before
// observers re-render — so the model store is populated in time for that render.
export const createModelStoreQueryCache = (store: Store) => new QueryCache({
onSuccess: (data, query) => {
const { modelType, courseId } = (query.meta ?? {}) as ModelStoreMeta;
if (modelType) {
store.dispatch(addModel({ modelType, model: { id: courseId, ...(data as Record<string, unknown>) } }));
}
},
});
7 changes: 7 additions & 0 deletions src/course-home/data/queryKeys.ts
Original file line number Diff line number Diff line change
@@ -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,
};
89 changes: 0 additions & 89 deletions src/course-home/data/redux.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
5 changes: 0 additions & 5 deletions src/course-home/data/thunks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getCourseHomeCourseMetadata,
getDatesTabData,
getExamsData,
getOutlineTabData,
getProgressTabData,
Expand Down Expand Up @@ -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);
}
Expand Down
23 changes: 15 additions & 8 deletions src/course-home/dates-tab/DatesTab.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -43,20 +45,25 @@ const DatesTab = () => {
};

return (
<>
<TabWithTimer
activeTabSlug="dates"
courseId={courseId}
courseStatus={{ metadataQuery, tabDataQuery }}
metadataModel="courseHomeMeta"
>
<div role="heading" aria-level="1" className="h2 my-3">
{intl.formatMessage(messages.title)}
</div>
{isSelfPaced && hasDeadlines && (
<>
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
<ShiftDatesAlert model="dates" />
<SuggestedScheduleHeader />
<BannerDatesUpgradeSlot courseId={courseId} logUpgradeLinkClick={logUpgradeLinkClick} />
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
</>
)}
<Timeline />
</>
</TabWithTimer>
);
};

Expand Down
10 changes: 2 additions & 8 deletions src/course-home/dates-tab/DatesTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,17 +33,13 @@ describe('DatesTab', () => {
store = initializeStore();
component = (
<AppProvider store={store}>
<QueryClientProvider client={createTestQueryClient()}>
<QueryClientProvider client={createTestQueryClient(store)}>
<UserMessagesProvider>
<ToastProvider>
<Routes>
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
)}
element={<DatesTab />}
/>
</Routes>
</ToastProvider>
Expand Down
6 changes: 2 additions & 4 deletions src/course-home/dates-tab/timeline/Day.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,9 +23,7 @@ const Day = ({
last,
}) => {
const intl = useIntl();
const {
courseId,
} = useSelector(state => state.courseHome);
const { courseId } = useParams();
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
Expand Down
6 changes: 2 additions & 4 deletions src/course-home/dates-tab/timeline/Timeline.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';

import { useModel } from '../../../generic/model-store';

import Day from './Day';
import { daycmp, isLearnerAssignment } from '../utils';

const Timeline = () => {
const {
courseId,
} = useSelector(state => state.courseHome);
const { courseId } = useParams();

const {
courseDateBlocks,
Expand Down
Loading