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
11 changes: 9 additions & 2 deletions src/course-home/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { logError } from '@edx/frontend-platform/logging';
import { useMutation, useQuery } from '@tanstack/react-query';

import type { RequestError } from '@src/data/http-error';
import { useToast, ToastContent } from '@src/generic/ToastContext';
import {
executePostFromPostEvent,
Expand Down Expand Up @@ -58,9 +59,15 @@ export const usePostEvent = () => {
});
};

export const useCourseHomeMeta = (courseId: string) => useQuery({
queryKey: courseHomeQueryKeys.metadata(courseId),
// Typed to only what we read off this query, not the whole (untyped) endpoint shape;
// other course-home fields are read via `useModel`/the bridge (until #1977).
export const useCourseHomeMeta = (courseId: string | undefined) => useQuery<
{ courseAccess?: { hasAccess: boolean } },
RequestError
>({
queryKey: courseHomeQueryKeys.metadata(courseId!),
queryFn: () => getCourseHomeCourseMetadata(courseId, 'outline'),
enabled: !!courseId,
meta: { modelType: 'courseHomeMeta', courseId },
});

Expand Down
2 changes: 1 addition & 1 deletion src/courseware/CoursewareContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('CoursewareContainer', () => {

component = (
<AppProvider store={store} wrapWithRouter={false}>
<QueryClientProvider client={createTestQueryClient()}>
<QueryClientProvider client={createTestQueryClient(store)}>
<UserMessagesProvider>
<ToastProvider>
<Routes>
Expand Down
3 changes: 3 additions & 0 deletions src/courseware/CoursewareContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getSequenceForUnitDeprecated,
saveSequencePosition,
} from './data';
import { useCourseStatusBridge } from './data/statusBridge';
import { TabPage } from '../tab-page';
import type { CourseStatus } from '../tab-page/TabPage';
import type { RootState } from '../store';
Expand Down Expand Up @@ -234,6 +235,8 @@ const CoursewareContainer = () => {
const firstSequenceId = useSelector(firstSequenceIdSelector);
const sectionViaSequenceId = useSelector(sectionViaSequenceIdSelector);

useCourseStatusBridge(routeCourseId);

const latest = useRef<any>();

const guards = useRef<any>();
Expand Down
28 changes: 24 additions & 4 deletions src/courseware/course/course-exit/CourseExit.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect } from 'react';

import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
import { Navigate, useParams } from 'react-router-dom';

import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress';
Expand All @@ -11,9 +10,13 @@ import { postUnsubscribeFromGoalReminders } from './data/api';
import { CourseExitViewCoursesPluginSlot } from '../../../plugin-slots/CourseExitPluginSlots';

import { useModel } from '../../../generic/model-store';
import { TabPage } from '../../../tab-page';
import { useCoursewareMetadata } from '../../data/apiHooks';
import { useCourseExitStatusBridge } from '../../data/statusBridge';
import { useCourseHomeMeta } from '../../../course-home/data/apiHooks';

const CourseExit = () => {
const { courseId } = useSelector(state => state.courseware);
const CourseExitContent = () => {
const { courseId } = useParams();
const {
certificateData,
courseExitPageIsActive,
Expand Down Expand Up @@ -66,4 +69,21 @@ const CourseExit = () => {
);
};

const CourseExit = () => {
const { courseId } = useParams();
const metadataQuery = useCoursewareMetadata(courseId);
const courseHomeMetaQuery = useCourseHomeMeta(courseId);
useCourseExitStatusBridge(courseId, metadataQuery, courseHomeMetaQuery);

return (
<TabPage
activeTabSlug="courseware"
courseId={courseId}
courseStatus={{ metadataQuery: courseHomeMetaQuery, tabDataQuery: metadataQuery }}
>
<CourseExitContent />
</TabPage>
);
};

export default CourseExit;
33 changes: 27 additions & 6 deletions src/courseware/course/course-exit/CourseExit.test.jsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { waitFor } from '@testing-library/react';
import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, Route, Routes } from 'react-router-dom';

import { fetchCourse } from '../../data';
import { getCourseMetadata } from '../../data/api';
import { getCourseHomeCourseMetadata } from '../../../course-home/data/api';
import { fetchCourseSuccess } from '../../data/slice';
import { addModel } from '../../../generic/model-store';
import { buildSimpleCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
import { buildOutlineFromBlocks } from '../../data/__factories__/learningSequencesOutline.factory';
import {
initializeMockApp, logUnhandledRequests, render, screen,
} from '../../../setupTest';
import initializeStore from '../../../store';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../../utils';
import { appendBrowserTimezoneToUrl } from '../../../utils';
import CourseCelebration from './CourseCelebration';
import CourseExit from './CourseExit';
import CourseInProgress from './CourseInProgress';
Expand Down Expand Up @@ -51,8 +55,25 @@ describe('Course Exit Pages', () => {
}

async function fetchAndRender(component) {
await executeThunk(fetchCourse(courseId), store.dispatch);
render(component, { store, wrapWithRouter: true });
const [metadata, homeMetadata] = await Promise.all([
getCourseMetadata(courseId),
getCourseHomeCourseMetadata(courseId, 'courseware'),
]);
store.dispatch(addModel({ modelType: 'coursewareMeta', model: metadata }));
store.dispatch(addModel({ modelType: 'courseHomeMeta', model: { id: courseId, ...homeMetadata } }));
store.dispatch(fetchCourseSuccess({ courseId }));
history.push(`/course/${courseId}`);
render(
<BrowserRouter>
<Routes>
<Route path="/course/:courseId" element={component} />
</Routes>
</BrowserRouter>,
{ store, wrapWithRouter: false },
);
if (screen.queryByRole('status')) {
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
}
}

beforeEach(() => {
Expand Down
22 changes: 10 additions & 12 deletions src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useDispatch } from 'react-redux';
import { renderHook } from '@testing-library/react';

import { logError } from '@edx/frontend-platform/logging';

import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { fetchCourse } from '@src/courseware/data';
import { coursewareQueryKeys } from '@src/courseware/data/queryKeys';
import { courseHomeQueryKeys } from '@src/course-home/data/queryKeys';
import { useEventListener } from '@src/generic/hooks';
import { useSequenceNavigationMetadata } from '@src/courseware/course/sequence/sequence-navigation/hooks';

Expand All @@ -15,6 +15,7 @@ import useIFrameBehavior, { iframeBehaviorState } from './useIFrameBehavior';

const mockNavigate = jest.fn();
const mockMutate = jest.fn();
const mockInvalidateQueries = jest.fn();

jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
Expand All @@ -29,16 +30,16 @@ jest.mock('react', () => ({
}));

jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));

jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));

jest.mock('@src/courseware/data', () => ({
fetchCourse: jest.fn(),
jest.mock('@tanstack/react-query', () => ({
...jest.requireActual('@tanstack/react-query'),
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}));
jest.mock('@src/course-home/data/thunks', () => ({
eventTypes: { POST_EVENT: 'post_event' },
Expand Down Expand Up @@ -72,9 +73,6 @@ const testIFrameHeight = 42;
const config = { LMS_BASE_URL: 'test-base-url' };
getConfig.mockReturnValue(config);

const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);

const postMessage = jest.fn();
const frame = {
contentWindow: { postMessage },
Expand Down Expand Up @@ -351,9 +349,8 @@ describe('useIFrameBehavior hook', () => {
result.current.handleIFrameLoad();
expect(sendTrackEvent).not.toHaveBeenCalled();
});
it('registers an event handler to process fetchCourse events.', () => {
it('invalidates the courseware queries on a post event.', () => {
mockState(defaultStateVals);
fetchCourse.mockReturnValue('fetch-course-action');
const { result } = renderHook(() => useIFrameBehavior(props));
result.current.handleIFrameLoad();
const event = {
Expand All @@ -375,8 +372,9 @@ describe('useIFrameBehavior hook', () => {

const { onSuccess } = mockMutate.mock.calls[0][1];
onSuccess();
expect(fetchCourse).toHaveBeenCalledWith('course-1');
expect(dispatch).toHaveBeenCalledWith('fetch-course-action');
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: coursewareQueryKeys.metadata('course-1') });
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: coursewareQueryKeys.outline('course-1') });
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: courseHomeQueryKeys.metadata('course-1') });
});
it('updates initial iframe visibility on load', () => {
const { result } = renderHook(() => useIFrameBehavior(props));
Expand Down
17 changes: 13 additions & 4 deletions src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { useState } from 'react';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { throttle } from 'lodash';

import { logError } from '@edx/frontend-platform/logging';

import { fetchCourse } from '@src/courseware/data';
import { usePostEvent } from '@src/course-home/data/apiHooks';
import { courseHomeQueryKeys } from '@src/course-home/data/queryKeys';
import { coursewareQueryKeys } from '@src/courseware/data/queryKeys';
import { eventTypes } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks';
import { getSequenceId } from '@src/courseware/data/selectors';
Expand All @@ -34,7 +36,7 @@ const useIFrameBehavior = ({
// Do not remove this hook. See function description.
useLoadBearingHook(id);

const dispatch = useDispatch();
const queryClient = useQueryClient();
const postEvent = usePostEvent();
const activeSequenceId = useSelector(getSequenceId);
const navigate = useNavigate();
Expand Down Expand Up @@ -164,7 +166,14 @@ const useIFrameBehavior = ({
}
postEvent.mutate(
{ postData: event.postData, researchEventData },
{ onSuccess: () => dispatch(fetchCourse(event.postData.bodyParams.courseId)) },
{
onSuccess: () => {
const eventCourseId = event.postData.bodyParams.courseId;
queryClient.invalidateQueries({ queryKey: coursewareQueryKeys.metadata(eventCourseId) });
queryClient.invalidateQueries({ queryKey: coursewareQueryKeys.outline(eventCourseId) });
queryClient.invalidateQueries({ queryKey: courseHomeQueryKeys.metadata(eventCourseId) });
},
},
);
};

Expand Down
75 changes: 75 additions & 0 deletions src/courseware/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { 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 { appendBrowserTimezoneToUrl } from '../../utils';
import { buildSimpleCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
import { buildOutlineFromBlocks } from './__factories__/learningSequencesOutline.factory';
import { createTestQueryClient, initializeMockApp } from '../../setupTest';
import initializeStore from '../../store';
import { normalizeLearningSequencesData } from './utils';
import { fetchCourseSuccess } from './slice';
import { sequenceIdsSelector } from './selectors';
import { useCoursewareMetadata, useCoursewareOutline } from './apiHooks';

initializeMockApp();

describe('courseware apiHooks — coursewareMeta bridge', () => {
const courseMetadata = Factory.build('courseMetadata');
const courseId = courseMetadata.id;
const { courseBlocks } = buildSimpleCourseBlocks(courseId);
const outlineResponse = buildOutlineFromBlocks(courseBlocks);
const normalizedOutline = normalizeLearningSequencesData(outlineResponse);
const expectedSectionIds = normalizedOutline.courses[courseId].sectionIds;
const expectedSequenceIds = expectedSectionIds.flatMap(
(id: string) => normalizedOutline.sections[id].sequenceIds,
);

let axiosMock: MockAdapter;
let store: ReturnType<typeof initializeStore>;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/${courseId}`;
const metadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`);

const coursewareMetaFor = (id: string) => (
store.getState().models as { coursewareMeta?: Record<string, { sectionIds?: string[]; title?: string }> }
).coursewareMeta?.[id];

beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
});

it('keeps coursewareMeta.sectionIds (and the sequence order nav needs) when metadata resolves after the outline', async () => {
let resolveMetadata: () => void = () => {};
axiosMock.onGet(outlineUrl).reply(200, outlineResponse);
axiosMock.onGet(metadataUrl).reply(() => new Promise((resolve) => {
resolveMetadata = () => resolve([200, courseMetadata]);
}));

const queryClient = createTestQueryClient(store);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
renderHook(
() => ({ meta: useCoursewareMetadata(courseId), outline: useCoursewareOutline(courseId) }),
{ wrapper },
);

// The outline resolves first and populates sectionIds.
await waitFor(() => expect(coursewareMetaFor(courseId)?.sectionIds).toEqual(expectedSectionIds));
store.dispatch(fetchCourseSuccess({ courseId }));
expect(sequenceIdsSelector(store.getState())).toEqual(expectedSequenceIds);

// Now let the metadata mirror land last.
resolveMetadata();
await waitFor(() => expect(coursewareMetaFor(courseId)?.title).toBe(courseMetadata.name));

// sectionIds must survive.
expect(coursewareMetaFor(courseId)?.sectionIds).toEqual(expectedSectionIds);
expect(sequenceIdsSelector(store.getState())).toEqual(expectedSequenceIds);
});
});
25 changes: 25 additions & 0 deletions src/courseware/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';

import { getCourseMetadata, getLearningSequencesOutline } from './api';
import { coursewareQueryKeys } from './queryKeys';

export const useCoursewareMetadata = (courseId: string | undefined) => useQuery({
queryKey: coursewareQueryKeys.metadata(courseId!),
queryFn: () => getCourseMetadata(courseId),
enabled: !!courseId,
meta: { models: [{ modelType: 'coursewareMeta', strategy: 'updateModel' }] },
});

export const useCoursewareOutline = (courseId: string | undefined) => useQuery({
queryKey: coursewareQueryKeys.outline(courseId!),
queryFn: () => getLearningSequencesOutline(courseId),
enabled: !!courseId,
meta: {
logStatusAs: { 403: 'info' },
models: [
{ modelType: 'coursewareMeta', strategy: 'updateModelsMap', source: 'courses' },
{ modelType: 'sections', strategy: 'addModelsMap', source: 'sections' },
{ modelType: 'sequences', strategy: 'updateModelsMap', source: 'sequences' },
],
},
});
7 changes: 7 additions & 0 deletions src/courseware/data/queryKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { appId } from '@src/constants';

export const coursewareQueryKeys = {
all: [appId, 'courseware'] as const,
metadata: (courseId: string) => [...coursewareQueryKeys.all, 'metadata', courseId] as const,
outline: (courseId: string) => [...coursewareQueryKeys.all, 'outline', courseId] as const,
};
Loading