Compare commits
1 Commits
bw/hackath
...
alangsto/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f93e663278 |
2
.env
2
.env
@@ -29,8 +29,6 @@ LOGO_WHITE_URL=''
|
|||||||
LEGACY_THEME_NAME=''
|
LEGACY_THEME_NAME=''
|
||||||
MARKETING_SITE_BASE_URL=''
|
MARKETING_SITE_BASE_URL=''
|
||||||
ORDER_HISTORY_URL=''
|
ORDER_HISTORY_URL=''
|
||||||
PROCTORED_EXAM_FAQ_URL=''
|
|
||||||
PROCTORED_EXAM_RULES_URL=''
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
SEARCH_CATALOG_URL=''
|
SEARCH_CATALOG_URL=''
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ LEGACY_THEME_NAME=''
|
|||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
PORT=2000
|
PORT=2000
|
||||||
PROCTORED_EXAM_FAQ_URL=''
|
|
||||||
PROCTORED_EXAM_RULES_URL=''
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ LEGACY_THEME_NAME=''
|
|||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
PORT=2000
|
PORT=2000
|
||||||
PROCTORED_EXAM_FAQ_URL=''
|
|
||||||
PROCTORED_EXAM_RULES_URL=''
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
|
|||||||
26405
package-lock.json
generated
26405
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,8 +32,8 @@
|
|||||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||||
"@edx/frontend-component-footer": "11.6.3",
|
"@edx/frontend-component-footer": "11.6.3",
|
||||||
"@edx/frontend-component-header": "3.6.4",
|
"@edx/frontend-component-header": "3.6.4",
|
||||||
"@edx/frontend-lib-special-exams": "2.10.0",
|
"@edx/frontend-lib-special-exams": "2.5.0",
|
||||||
"@edx/frontend-platform": "4.1.0",
|
"@edx/frontend-platform": "3.4.1",
|
||||||
"@edx/paragon": "20.28.4",
|
"@edx/paragon": "20.28.4",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||||
@@ -45,7 +45,6 @@
|
|||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"core-js": "3.22.2",
|
"core-js": "3.22.2",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"html-react-parser": "^3.0.15",
|
|
||||||
"js-cookie": "3.0.1",
|
"js-cookie": "3.0.1",
|
||||||
"lodash.camelcase": "4.3.0",
|
"lodash.camelcase": "4.3.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { PageRoute } from '@edx/frontend-platform/react';
|
|||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
import PageLoading from '../generic/PageLoading';
|
import PageLoading from '../generic/PageLoading';
|
||||||
|
|
||||||
import DecodePageRoute from '../decode-page-route';
|
|
||||||
|
|
||||||
const CoursewareRedirectLandingPage = () => {
|
const CoursewareRedirectLandingPage = () => {
|
||||||
const { path } = useRouteMatch();
|
const { path } = useRouteMatch();
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +21,7 @@ const CoursewareRedirectLandingPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<DecodePageRoute
|
<PageRoute
|
||||||
path={`${path}/survey/:courseId`}
|
path={`${path}/survey/:courseId`}
|
||||||
render={({ match }) => {
|
render={({ match }) => {
|
||||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
|
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
|
||||||
@@ -42,7 +40,7 @@ const CoursewareRedirectLandingPage = () => {
|
|||||||
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
|
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DecodePageRoute
|
<PageRoute
|
||||||
path={`${path}/home/:courseId`}
|
path={`${path}/home/:courseId`}
|
||||||
render={({ match }) => {
|
render={({ match }) => {
|
||||||
global.location.assign(`/course/${match.params.courseId}/home`);
|
global.location.assign(`/course/${match.params.courseId}/home`);
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import { breakpoints } from '@edx/paragon';
|
import { breakpoints } from '@edx/paragon';
|
||||||
import {
|
import {
|
||||||
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||||
} from '../../setupTest';
|
} from '../../setupTest';
|
||||||
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
|
|
||||||
import { handleNextSectionCelebration } from './celebration';
|
import { handleNextSectionCelebration } from './celebration';
|
||||||
import * as celebrationUtils from './celebration/utils';
|
import * as celebrationUtils from './celebration/utils';
|
||||||
import Course from './Course';
|
import Course from './Course';
|
||||||
import { executeThunk } from '../../utils';
|
|
||||||
import * as thunks from '../data/thunks';
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
jest.mock('@edx/frontend-platform/analytics');
|
||||||
|
|
||||||
@@ -49,28 +43,6 @@ describe('Course', () => {
|
|||||||
setItemSpy.mockRestore();
|
setItemSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupDiscussionSidebar = async (storageValue = false) => {
|
|
||||||
localStorage.clear();
|
|
||||||
const testStore = await initializeTestStore({ provider: 'openedx' });
|
|
||||||
const state = testStore.getState();
|
|
||||||
const { courseware: { courseId } } = state;
|
|
||||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(200, { provider: 'openedx' });
|
|
||||||
const topicsResponse = buildTopicsFromUnits(state.models.units);
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
|
|
||||||
.reply(200, topicsResponse);
|
|
||||||
|
|
||||||
await executeThunk(thunks.getCourseDiscussionTopics(courseId), testStore.dispatch);
|
|
||||||
const [firstUnitId] = Object.keys(state.models.units);
|
|
||||||
mockData.unitId = firstUnitId;
|
|
||||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
|
||||||
mockData.sequenceId = firstSequenceId;
|
|
||||||
if (storageValue !== null) {
|
|
||||||
localStorage.setItem('showDiscussionSidebar', storageValue);
|
|
||||||
}
|
|
||||||
await render(<Course {...mockData} />, { store: testStore });
|
|
||||||
};
|
|
||||||
|
|
||||||
it('loads learning sequence', async () => {
|
it('loads learning sequence', async () => {
|
||||||
render(<Course {...mockData} />);
|
render(<Course {...mockData} />);
|
||||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||||
@@ -131,7 +103,6 @@ describe('Course', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays notification trigger and toggles active class on click', async () => {
|
it('displays notification trigger and toggles active class on click', async () => {
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
|
||||||
render(<Course {...mockData} />);
|
render(<Course {...mockData} />);
|
||||||
|
|
||||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||||
@@ -143,7 +114,6 @@ describe('Course', () => {
|
|||||||
|
|
||||||
it('handles click to open/close notification tray', async () => {
|
it('handles click to open/close notification tray', async () => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
|
||||||
render(<Course {...mockData} />);
|
render(<Course {...mockData} />);
|
||||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||||
@@ -174,7 +144,6 @@ describe('Course', () => {
|
|||||||
|
|
||||||
it('handles sessionStorage from a different course for the notification tray', async () => {
|
it('handles sessionStorage from a different course for the notification tray', async () => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
|
||||||
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
|
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
|
||||||
|
|
||||||
// set sessionStorage for a different course before rendering Course
|
// set sessionStorage for a different course before rendering Course
|
||||||
@@ -217,34 +186,6 @@ describe('Course', () => {
|
|||||||
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
[
|
|
||||||
{ value: true, visible: true },
|
|
||||||
{ value: false, visible: false },
|
|
||||||
{ value: null, visible: true },
|
|
||||||
].forEach(async ({ value, visible }) => (
|
|
||||||
it(`discussion sidebar is ${visible ? 'shown' : 'hidden'} when localstorage value is ${value}`, async () => {
|
|
||||||
await setupDiscussionSidebar(value);
|
|
||||||
const element = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
|
|
||||||
if (visible) {
|
|
||||||
expect(element).not.toHaveClass('d-none');
|
|
||||||
} else {
|
|
||||||
expect(element).toHaveClass('d-none');
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
|
|
||||||
[
|
|
||||||
{ value: true, result: 'false' },
|
|
||||||
{ value: false, result: 'true' },
|
|
||||||
].forEach(async ({ value, result }) => (
|
|
||||||
it(`Discussion sidebar storage value is ${!value} when sidebar is ${value ? 'closed' : 'open'}`, async () => {
|
|
||||||
await setupDiscussionSidebar(value);
|
|
||||||
await act(async () => {
|
|
||||||
const button = await screen.queryByRole('button', { name: /Show discussions tray/i });
|
|
||||||
button.click();
|
|
||||||
});
|
|
||||||
expect(localStorage.getItem('showDiscussionSidebar')).toBe(result);
|
|
||||||
})));
|
|
||||||
|
|
||||||
it('passes handlers to the sequence', async () => {
|
it('passes handlers to the sequence', async () => {
|
||||||
const nextSequenceHandler = jest.fn();
|
const nextSequenceHandler = jest.fn();
|
||||||
const previousSequenceHandler = jest.fn();
|
const previousSequenceHandler = jest.fn();
|
||||||
|
|||||||
257
src/courseware/course/sequence/Unit.jsx
Normal file
257
src/courseware/course/sequence/Unit.jsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
|
||||||
|
import { Modal } from '@edx/paragon';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, {
|
||||||
|
Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { processEvent } from '../../../course-home/data/thunks';
|
||||||
|
import { useEventListener } from '../../../generic/hooks';
|
||||||
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
import PageLoading from '../../../generic/PageLoading';
|
||||||
|
import { fetchCourse } from '../../data';
|
||||||
|
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||||
|
import ShareButton from '../share/ShareButton';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const HonorCode = React.lazy(() => import('./honor-code'));
|
||||||
|
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||||
|
*
|
||||||
|
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||||
|
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||||
|
* block that iframes external course content.
|
||||||
|
|
||||||
|
* This policy was selected in conference with the edX Security Working Group.
|
||||||
|
* Changes to it should be vetted by them (security@edx.org).
|
||||||
|
*/
|
||||||
|
const IFRAME_FEATURE_POLICY = (
|
||||||
|
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||||
|
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||||
|
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||||
|
* state.
|
||||||
|
*
|
||||||
|
* We were able to solve this error by using a layout effect to update some component state, which
|
||||||
|
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||||
|
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||||
|
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||||
|
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||||
|
*
|
||||||
|
* If we remove this hook when one of these happens:
|
||||||
|
* 1. React figures out that there's an issue here and fixes a bug.
|
||||||
|
* 2. We cease to use an iframe for unit rendering.
|
||||||
|
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||||
|
* 4. We stop supporting Firefox.
|
||||||
|
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||||
|
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||||
|
* so we can fix it.
|
||||||
|
*
|
||||||
|
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||||
|
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||||
|
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||||
|
*/
|
||||||
|
function useLoadBearingHook(id) {
|
||||||
|
const setValue = useState(0)[1];
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setValue(currentValue => currentValue + 1);
|
||||||
|
}, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendUrlHashToFrame(frame) {
|
||||||
|
const { hash } = window.location;
|
||||||
|
if (hash) {
|
||||||
|
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||||
|
// hash within the iframe.
|
||||||
|
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Unit = ({
|
||||||
|
courseId,
|
||||||
|
format,
|
||||||
|
onLoaded,
|
||||||
|
id,
|
||||||
|
intl,
|
||||||
|
}) => {
|
||||||
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
|
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||||
|
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||||
|
if (format) {
|
||||||
|
iframeUrl += `&format=${format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [iframeHeight, setIframeHeight] = useState(0);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const [showError, setShowError] = useState(false);
|
||||||
|
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||||
|
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||||
|
|
||||||
|
const unit = useModel('units', id);
|
||||||
|
const course = useModel('coursewareMeta', courseId);
|
||||||
|
const {
|
||||||
|
contentTypeGatingEnabled,
|
||||||
|
userNeedsIntegritySignature,
|
||||||
|
} = course;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
// Do not remove this hook. See function description.
|
||||||
|
useLoadBearingHook(id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userNeedsIntegritySignature && unit.graded) {
|
||||||
|
setShouldDisplayHonorCode(true);
|
||||||
|
} else {
|
||||||
|
setShouldDisplayHonorCode(false);
|
||||||
|
}
|
||||||
|
}, [userNeedsIntegritySignature]);
|
||||||
|
|
||||||
|
const receiveMessage = useCallback(({ data }) => {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
} = data;
|
||||||
|
if (type === 'plugin.resize') {
|
||||||
|
setIframeHeight(payload.height);
|
||||||
|
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||||
|
setHasLoaded(true);
|
||||||
|
if (onLoaded) {
|
||||||
|
onLoaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === 'plugin.modal') {
|
||||||
|
payload.open = true;
|
||||||
|
setModalOptions(payload);
|
||||||
|
} else if (data.offset) {
|
||||||
|
// We listen for this message from LMS to know when the page needs to
|
||||||
|
// be scrolled to another location on the page.
|
||||||
|
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||||
|
}
|
||||||
|
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||||
|
useEventListener('message', receiveMessage);
|
||||||
|
useEffect(() => {
|
||||||
|
sendUrlHashToFrame(document.getElementById('unit-iframe'));
|
||||||
|
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="unit">
|
||||||
|
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||||
|
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
|
||||||
|
<BookmarkButton
|
||||||
|
unitId={unit.id}
|
||||||
|
isBookmarked={unit.bookmarked}
|
||||||
|
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||||
|
/>
|
||||||
|
{/* TODO: social share exp. Need to remove later */}
|
||||||
|
{(window.expSocialShareAboutUrls && window.expSocialShareAboutUrls[unit.id] !== undefined) && (
|
||||||
|
<ShareButton url={window.expSocialShareAboutUrls[unit.id]} />
|
||||||
|
)}
|
||||||
|
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
||||||
|
<Suspense
|
||||||
|
fallback={(
|
||||||
|
<PageLoading
|
||||||
|
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LockPaywall courseId={courseId} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
{shouldDisplayHonorCode && (
|
||||||
|
<Suspense
|
||||||
|
fallback={(
|
||||||
|
<PageLoading
|
||||||
|
srMessage={intl.formatMessage(messages.loadingHonorCode)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HonorCode courseId={courseId} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
{!shouldDisplayHonorCode && !hasLoaded && !showError && (
|
||||||
|
<PageLoading
|
||||||
|
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!shouldDisplayHonorCode && !hasLoaded && showError && (
|
||||||
|
<ErrorPage />
|
||||||
|
)}
|
||||||
|
{modalOptions.open && (
|
||||||
|
<Modal
|
||||||
|
body={(
|
||||||
|
<>
|
||||||
|
{modalOptions.body
|
||||||
|
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||||
|
: (
|
||||||
|
<iframe
|
||||||
|
title={modalOptions.title}
|
||||||
|
allow={IFRAME_FEATURE_POLICY}
|
||||||
|
frameBorder="0"
|
||||||
|
src={modalOptions.url}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
onClose={() => { setModalOptions({ open: false }); }}
|
||||||
|
open
|
||||||
|
dialogClassName="modal-lti"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!shouldDisplayHonorCode && (
|
||||||
|
<div className="unit-iframe-wrapper">
|
||||||
|
<iframe
|
||||||
|
id="unit-iframe"
|
||||||
|
title={unit.title}
|
||||||
|
src={iframeUrl}
|
||||||
|
allow={IFRAME_FEATURE_POLICY}
|
||||||
|
allowFullScreen
|
||||||
|
height={iframeHeight}
|
||||||
|
scrolling="no"
|
||||||
|
referrerPolicy="origin"
|
||||||
|
onLoad={() => {
|
||||||
|
// onLoad *should* only fire after everything in the iframe has finished its own load events.
|
||||||
|
// Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
||||||
|
// for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
||||||
|
// could have given us a 4xx or 5xx response.
|
||||||
|
if (!hasLoaded) {
|
||||||
|
setShowError(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onmessage = (e) => {
|
||||||
|
if (e.data.event_name) {
|
||||||
|
dispatch(processEvent(e.data, fetchCourse));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Unit.propTypes = {
|
||||||
|
courseId: PropTypes.string.isRequired,
|
||||||
|
format: PropTypes.string,
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
onLoaded: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
Unit.defaultProps = {
|
||||||
|
format: null,
|
||||||
|
onLoaded: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(Unit);
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
|
||||||
import { Modal } from '@edx/paragon';
|
|
||||||
import PageLoading from '../../../../generic/PageLoading';
|
|
||||||
import LocalIFrame from './LocalIFrame';
|
|
||||||
import { renderers } from './constants';
|
|
||||||
import hooks from './hooks';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
|
||||||
*
|
|
||||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
|
||||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
|
||||||
* block that iframes external course content.
|
|
||||||
|
|
||||||
* This policy was selected in conference with the edX Security Working Group.
|
|
||||||
* Changes to it should be vetted by them (security@edx.org).
|
|
||||||
*/
|
|
||||||
const IFRAME_FEATURE_POLICY = (
|
|
||||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
|
||||||
);
|
|
||||||
|
|
||||||
const ContentIFrame = ({
|
|
||||||
iframeUrl,
|
|
||||||
showContent,
|
|
||||||
loadingMessage,
|
|
||||||
id,
|
|
||||||
elementId,
|
|
||||||
onLoaded,
|
|
||||||
title,
|
|
||||||
childBlocks,
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
hasLoaded,
|
|
||||||
showError,
|
|
||||||
modalOptions,
|
|
||||||
handleModalClose,
|
|
||||||
handleIFrameLoad,
|
|
||||||
iframeHeight,
|
|
||||||
} = hooks.useIFrameBehavior({
|
|
||||||
id,
|
|
||||||
elementId,
|
|
||||||
onLoaded,
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderModal = () => (
|
|
||||||
<Modal
|
|
||||||
body={(
|
|
||||||
<>
|
|
||||||
{modalOptions.body
|
|
||||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
|
||||||
: (
|
|
||||||
<iframe
|
|
||||||
title={modalOptions.title}
|
|
||||||
allow={IFRAME_FEATURE_POLICY}
|
|
||||||
frameBorder="0"
|
|
||||||
src={modalOptions.url}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100vh',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
onClose={handleModalClose}
|
|
||||||
open
|
|
||||||
dialogClassName="modal-lti"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderChild = (childBlock) => {
|
|
||||||
const Renderer = renderers[childBlock.type];
|
|
||||||
return (<Renderer key={childBlock.id} {...childBlock.student_view_data} block={childBlock} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (iframeUrl) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!hasLoaded && (
|
|
||||||
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
|
|
||||||
)}
|
|
||||||
<div className="unit-iframe-wrapper">
|
|
||||||
<iframe
|
|
||||||
id={elementId}
|
|
||||||
title={title}
|
|
||||||
src={iframeUrl}
|
|
||||||
allow={IFRAME_FEATURE_POLICY}
|
|
||||||
allowFullScreen
|
|
||||||
height={iframeHeight}
|
|
||||||
scrolling="no"
|
|
||||||
referrerPolicy="origin"
|
|
||||||
onLoad={handleIFrameLoad}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{childBlocks.map(renderChild)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showContent && renderContent()}
|
|
||||||
{modalOptions.open && renderModal()}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ContentIFrame.propTypes = {
|
|
||||||
iframeUrl: PropTypes.string,
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
showContent: PropTypes.bool.isRequired,
|
|
||||||
loadingMessage: PropTypes.node.isRequired,
|
|
||||||
elementId: PropTypes.string.isRequired,
|
|
||||||
onLoaded: PropTypes.func,
|
|
||||||
title: PropTypes.node.isRequired,
|
|
||||||
childBlocks: PropTypes.arrayOf(PropTypes.shape({
|
|
||||||
type: PropTypes.string,
|
|
||||||
student_view_data: PropTypes.shape({
|
|
||||||
enabled: PropTypes.bool,
|
|
||||||
}),
|
|
||||||
})).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
ContentIFrame.defaultProps = {
|
|
||||||
iframeUrl: null,
|
|
||||||
onLoaded: () => ({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContentIFrame;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
const LocalIFrame = ({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [contentRef, setContentRef] = React.useState(null);
|
|
||||||
const mountNode = contentRef?.contentWindow?.document?.body;
|
|
||||||
return (
|
|
||||||
<iframe title={title} {...props} ref={setContentRef}>
|
|
||||||
{mountNode && createPortal(children, mountNode)}
|
|
||||||
</iframe>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
LocalIFrame.propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
title: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
export default LocalIFrame;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import HTMLRenderer from './renderers/HTMLRenderer';
|
|
||||||
|
|
||||||
export const renderers = {
|
|
||||||
html: HTMLRenderer,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FRendlyTypes = Object.keys(renderers);
|
|
||||||
|
|
||||||
export default {
|
|
||||||
renderers,
|
|
||||||
FRendlyTypes,
|
|
||||||
};
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import { processEvent } from '../../../../course-home/data/thunks';
|
|
||||||
import { useEventListener } from '../../../../generic/hooks';
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
|
||||||
import { fetchCourse } from '../../../data';
|
|
||||||
|
|
||||||
import { FRendlyTypes } from './constants';
|
|
||||||
|
|
||||||
const useFetchStudentData = ({
|
|
||||||
id,
|
|
||||||
}) => {
|
|
||||||
const [blocks, setBlocks] = useState(null);
|
|
||||||
const [children, setChildren] = useState(null);
|
|
||||||
const [isFRendly, setIsFRendly] = useState(false);
|
|
||||||
|
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (children) {
|
|
||||||
setIsFRendly(children.every(child => FRendlyTypes.includes(child.type)));
|
|
||||||
}
|
|
||||||
}, [children, setIsFRendly]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (blocks) {
|
|
||||||
setChildren(blocks[id].children.map(childID => blocks[childID]));
|
|
||||||
}
|
|
||||||
}, [blocks, setChildren]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let sequenceUrl;
|
|
||||||
if (authenticatedUser) {
|
|
||||||
const { username } = authenticatedUser;
|
|
||||||
sequenceUrl = `${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/${id}?username=${username}&requested_fields=children&depth=all&student_view_data=video,html`;
|
|
||||||
getAuthenticatedHttpClient().get(sequenceUrl).then(response => {
|
|
||||||
console.log({ response });
|
|
||||||
setBlocks(response.data.blocks);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [authenticatedUser, setBlocks]);
|
|
||||||
console.log({ isFRendly, children });
|
|
||||||
return { children, isFRendly };
|
|
||||||
};
|
|
||||||
|
|
||||||
const useUnitData = ({
|
|
||||||
courseId,
|
|
||||||
format,
|
|
||||||
id,
|
|
||||||
}) => {
|
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
|
||||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
|
||||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
|
||||||
if (format) {
|
|
||||||
iframeUrl += `&format=${format}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isFRendly, children } = useFetchStudentData({ id });
|
|
||||||
|
|
||||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
|
||||||
|
|
||||||
const unit = useModel('units', id);
|
|
||||||
const course = useModel('coursewareMeta', courseId);
|
|
||||||
const {
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
userNeedsIntegritySignature,
|
|
||||||
} = course;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userNeedsIntegritySignature && unit.graded) {
|
|
||||||
setShouldDisplayHonorCode(true);
|
|
||||||
} else {
|
|
||||||
setShouldDisplayHonorCode(false);
|
|
||||||
}
|
|
||||||
}, [userNeedsIntegritySignature]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
iframeUrl,
|
|
||||||
shouldDisplayHonorCode,
|
|
||||||
unit,
|
|
||||||
isFRendly,
|
|
||||||
children,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
|
||||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
|
||||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
|
||||||
* state.
|
|
||||||
*
|
|
||||||
* We were able to solve this error by using a layout effect to update some component state, which
|
|
||||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
|
||||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
|
||||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
|
||||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
|
||||||
*
|
|
||||||
* If we remove this hook when one of these happens:
|
|
||||||
* 1. React figures out that there's an issue here and fixes a bug.
|
|
||||||
* 2. We cease to use an iframe for unit rendering.
|
|
||||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
|
||||||
* 4. We stop supporting Firefox.
|
|
||||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
|
||||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
|
||||||
* so we can fix it.
|
|
||||||
*
|
|
||||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
|
||||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
|
||||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
|
||||||
*/
|
|
||||||
export const useLoadBearingHook = (id) => {
|
|
||||||
const setValue = useState(0)[1];
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setValue(currentValue => currentValue + 1);
|
|
||||||
}, [id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendUrlHashToFrame = (frame) => {
|
|
||||||
const { hash } = window.location;
|
|
||||||
if (hash) {
|
|
||||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
|
||||||
// hash within the iframe.
|
|
||||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const useIFrameBehavior = ({
|
|
||||||
id,
|
|
||||||
elementId,
|
|
||||||
onLoaded,
|
|
||||||
}) => {
|
|
||||||
// Do not remove this hook. See function description.
|
|
||||||
useLoadBearingHook(id);
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const [iframeHeight, setIframeHeight] = useState(0);
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
|
||||||
const [showError, setShowError] = useState(false);
|
|
||||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sendUrlHashToFrame(document.getElementById(elementId));
|
|
||||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
|
||||||
|
|
||||||
const receiveMessage = useCallback(({ data }) => {
|
|
||||||
const { type, payload } = data;
|
|
||||||
if (type === 'plugin.resize') {
|
|
||||||
setIframeHeight(payload.height);
|
|
||||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
|
||||||
setHasLoaded(true);
|
|
||||||
if (onLoaded) {
|
|
||||||
onLoaded();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === 'plugin.modal') {
|
|
||||||
payload.open = true;
|
|
||||||
setModalOptions(payload);
|
|
||||||
} else if (data.offset) {
|
|
||||||
// We listen for this message from LMS to know when the page needs to
|
|
||||||
// be scrolled to another location on the page.
|
|
||||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
|
||||||
}
|
|
||||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
|
||||||
useEventListener('message', receiveMessage);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* onLoad *should* only fire after everything in the iframe has finished its own load events.
|
|
||||||
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
|
||||||
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
|
||||||
* could have given us a 4xx or 5xx response.
|
|
||||||
*/
|
|
||||||
const handleIFrameLoad = () => {
|
|
||||||
if (!hasLoaded) {
|
|
||||||
setShowError(true);
|
|
||||||
}
|
|
||||||
window.onmessage = (e) => {
|
|
||||||
if (e.data.event_name) {
|
|
||||||
dispatch(processEvent(e.data, fetchCourse));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setModalOptions({ open: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
iframeHeight,
|
|
||||||
handleCloseModal,
|
|
||||||
modalOptions,
|
|
||||||
handleIFrameLoad,
|
|
||||||
showError,
|
|
||||||
hasLoaded,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
useIFrameBehavior,
|
|
||||||
useUnitData,
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Suspense } from 'react';
|
|
||||||
import PageLoading from '../../../../generic/PageLoading';
|
|
||||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
|
||||||
import messages from '../messages';
|
|
||||||
import ContentIFrame from './ContentIFrame';
|
|
||||||
import hooks from './hooks';
|
|
||||||
|
|
||||||
const HonorCode = React.lazy(() => import('../honor-code'));
|
|
||||||
const LockPaywall = React.lazy(() => import('../lock-paywall'));
|
|
||||||
|
|
||||||
const Unit = ({
|
|
||||||
courseId,
|
|
||||||
format,
|
|
||||||
onLoaded,
|
|
||||||
id,
|
|
||||||
}) => {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const {
|
|
||||||
unit,
|
|
||||||
contentTypeGatingEnabled,
|
|
||||||
shouldDisplayHonorCode,
|
|
||||||
iframeUrl,
|
|
||||||
isFRendly,
|
|
||||||
children,
|
|
||||||
} = hooks.useUnitData({
|
|
||||||
courseId,
|
|
||||||
format,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="unit">
|
|
||||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
|
||||||
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
|
|
||||||
<BookmarkButton
|
|
||||||
unitId={unit.id}
|
|
||||||
isBookmarked={unit.bookmarked}
|
|
||||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
|
||||||
/>
|
|
||||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
|
||||||
<Suspense
|
|
||||||
fallback={(
|
|
||||||
<PageLoading
|
|
||||||
srMessage={formatMessage(messages.loadingLockedContent)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LockPaywall courseId={courseId} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{shouldDisplayHonorCode && (
|
|
||||||
<Suspense
|
|
||||||
fallback={(
|
|
||||||
<PageLoading
|
|
||||||
srMessage={formatMessage(messages.loadingHonorCode)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HonorCode courseId={courseId} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
<ContentIFrame
|
|
||||||
showContent={!shouldDisplayHonorCode}
|
|
||||||
{...(isFRendly ? { childBlocks: children } : { iframeUrl })}
|
|
||||||
loadingMessage={formatMessage(messages.loadingSequence)}
|
|
||||||
id={id}
|
|
||||||
elementId="unit-iframe"
|
|
||||||
onLoaded={onLoaded}
|
|
||||||
title={unit.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Unit.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
format: PropTypes.string,
|
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
onLoaded: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
Unit.defaultProps = {
|
|
||||||
format: null,
|
|
||||||
onLoaded: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Unit;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import parse from 'html-react-parser';
|
|
||||||
|
|
||||||
const HTMLRenderer = ({ html }) => {
|
|
||||||
console.log({ html });
|
|
||||||
return (<div dangerouslySetInnerHTML={{ __html: html }} />);
|
|
||||||
// return parse(html);
|
|
||||||
};
|
|
||||||
|
|
||||||
HTMLRenderer.propTypes = {
|
|
||||||
html: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HTMLRenderer;
|
|
||||||
@@ -19,17 +19,9 @@ const SidebarProvider = ({
|
|||||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||||
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
|
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
|
||||||
const query = new URLSearchParams(window.location.search);
|
const initialSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
|
||||||
if (query.get('sidebar') === 'true') {
|
|
||||||
localStorage.setItem('showDiscussionSidebar', true);
|
|
||||||
}
|
|
||||||
const showDiscussionSidebar = localStorage.getItem('showDiscussionSidebar') !== 'false';
|
|
||||||
const showNotificationSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
|
|
||||||
? SIDEBARS.NOTIFICATIONS.ID
|
? SIDEBARS.NOTIFICATIONS.ID
|
||||||
: null;
|
: null;
|
||||||
const initialSidebar = showDiscussionSidebar
|
|
||||||
? SIDEBARS.DISCUSSIONS.ID
|
|
||||||
: showNotificationSidebar;
|
|
||||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||||
@@ -49,11 +41,6 @@ const SidebarProvider = ({
|
|||||||
|
|
||||||
const toggleSidebar = useCallback((sidebarId) => {
|
const toggleSidebar = useCallback((sidebarId) => {
|
||||||
// Switch to new sidebar or hide the current sidebar
|
// Switch to new sidebar or hide the current sidebar
|
||||||
if (currentSidebar === SIDEBARS.DISCUSSIONS.ID) {
|
|
||||||
localStorage.setItem('showDiscussionSidebar', false);
|
|
||||||
} else if (sidebarId === SIDEBARS.DISCUSSIONS.ID) {
|
|
||||||
localStorage.setItem('showDiscussionSidebar', true);
|
|
||||||
}
|
|
||||||
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
|
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
|
||||||
}, [currentSidebar]);
|
}, [currentSidebar]);
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const SidebarBase = ({
|
|||||||
'min-vh-100': !shouldDisplayFullScreen,
|
'min-vh-100': !shouldDisplayFullScreen,
|
||||||
'd-none': currentSidebar !== sidebarId,
|
'd-none': currentSidebar !== sidebarId,
|
||||||
}, className)}
|
}, className)}
|
||||||
data-testid={`sidebar-${sidebarId}`}
|
|
||||||
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
|
||||||
<div>
|
|
||||||
PageRoute: {
|
|
||||||
"computedMatch": {
|
|
||||||
"path": "/course/:courseId/home",
|
|
||||||
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
|
|
||||||
"isExact": true,
|
|
||||||
"params": {
|
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PageRoute } from '@edx/frontend-platform/react';
|
|
||||||
import React from 'react';
|
|
||||||
import { useHistory, generatePath } from 'react-router';
|
|
||||||
|
|
||||||
export const decodeUrl = (encodedUrl) => {
|
|
||||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
|
||||||
if (encodedUrl === decodedUrl) {
|
|
||||||
return encodedUrl;
|
|
||||||
}
|
|
||||||
return decodeUrl(decodedUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DecodePageRoute = (props) => {
|
|
||||||
const history = useHistory();
|
|
||||||
if (props.computedMatch) {
|
|
||||||
const { url, path, params } = props.computedMatch;
|
|
||||||
|
|
||||||
Object.keys(params).forEach((param) => {
|
|
||||||
// only decode params not the entire url.
|
|
||||||
// it is just to be safe and less prone to errors
|
|
||||||
params[param] = decodeUrl(params[param]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const newUrl = generatePath(path, params);
|
|
||||||
|
|
||||||
// if the url get decoded, reroute to the decoded url
|
|
||||||
if (newUrl !== url) {
|
|
||||||
history.replace(newUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <PageRoute {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
DecodePageRoute.propTypes = {
|
|
||||||
computedMatch: PropTypes.shape({
|
|
||||||
url: PropTypes.string.isRequired,
|
|
||||||
path: PropTypes.string.isRequired,
|
|
||||||
// eslint-disable-next-line react/forbid-prop-types
|
|
||||||
params: PropTypes.any,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
DecodePageRoute.defaultProps = {
|
|
||||||
computedMatch: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DecodePageRoute;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import { createMemoryHistory } from 'history';
|
|
||||||
import { Router, matchPath } from 'react-router';
|
|
||||||
import DecodePageRoute, { decodeUrl } from '.';
|
|
||||||
|
|
||||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
|
||||||
const encodedCourseId = encodeURIComponent(decodedCourseId);
|
|
||||||
const deepEncodedCourseId = (() => {
|
|
||||||
let path = encodedCourseId;
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
path = encodeURIComponent(path);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
})();
|
|
||||||
|
|
||||||
jest.mock('@edx/frontend-platform/react', () => ({
|
|
||||||
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderPage = (props) => {
|
|
||||||
const memHistory = createMemoryHistory({
|
|
||||||
initialEntries: [props?.path],
|
|
||||||
});
|
|
||||||
|
|
||||||
const history = {
|
|
||||||
...memHistory,
|
|
||||||
replace: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<Router history={history}>
|
|
||||||
<DecodePageRoute computedMatch={props} />
|
|
||||||
</Router>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
container,
|
|
||||||
history,
|
|
||||||
props,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('DecodePageRoute', () => {
|
|
||||||
it('should not modify the url if it does not need to be decoded', () => {
|
|
||||||
const props = matchPath(`/course/${decodedCourseId}/home`, {
|
|
||||||
path: '/course/:courseId/home',
|
|
||||||
});
|
|
||||||
const { container, history } = renderPage(props);
|
|
||||||
|
|
||||||
expect(props.url).toContain(decodedCourseId);
|
|
||||||
expect(history.replace).not.toHaveBeenCalled();
|
|
||||||
expect(container).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decode the url and replace the history if necessary', () => {
|
|
||||||
const props = matchPath(`/course/${encodedCourseId}/home`, {
|
|
||||||
path: '/course/:courseId/home',
|
|
||||||
});
|
|
||||||
const { history } = renderPage(props);
|
|
||||||
|
|
||||||
expect(props.url).not.toContain(decodedCourseId);
|
|
||||||
expect(props.url).toContain(encodedCourseId);
|
|
||||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should decode the url multiple times if necessary', () => {
|
|
||||||
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
|
|
||||||
path: '/course/:courseId/home',
|
|
||||||
});
|
|
||||||
const { history } = renderPage(props);
|
|
||||||
|
|
||||||
expect(props.url).not.toContain(decodedCourseId);
|
|
||||||
expect(props.url).toContain(deepEncodedCourseId);
|
|
||||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only decode the url params and not the entire url', () => {
|
|
||||||
const decodedUnitId = 'some+thing';
|
|
||||||
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
|
||||||
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
|
|
||||||
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
|
||||||
});
|
|
||||||
const { history } = renderPage(props);
|
|
||||||
|
|
||||||
const decodedUrls = history.replace.mock.calls[0][0].split('/');
|
|
||||||
|
|
||||||
// unitId get decoded
|
|
||||||
expect(decodedUrls.pop()).toContain(decodedUnitId);
|
|
||||||
|
|
||||||
// path remain encoded
|
|
||||||
expect(decodedUrls.pop()).toContain(encodedUnitId);
|
|
||||||
|
|
||||||
// courseId get decoded
|
|
||||||
expect(decodedUrls.pop()).toContain(decodedCourseId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('decodeUrl', () => {
|
|
||||||
expect(decodeUrl(decodedCourseId)).toEqual(decodedCourseId);
|
|
||||||
expect(decodeUrl(encodedCourseId)).toEqual(decodedCourseId);
|
|
||||||
expect(decodeUrl(deepEncodedCourseId)).toEqual(decodedCourseId);
|
|
||||||
});
|
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"learning.outline.sequence-due-date-set": "{description} échéance {assignmentDue}",
|
"learning.outline.sequence-due-date-set": "{description} échéance {assignmentDue}",
|
||||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||||
"progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.",
|
"progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.",
|
||||||
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre attestation maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
|
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre certificat maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
|
||||||
"courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
|
"courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
|
||||||
"progress.certificateStatus.notPassingHeader": "État de l'attestation",
|
"progress.certificateStatus.notPassingHeader": "État de l'attestation",
|
||||||
"progress.certificateStatus.notPassingBody": "Pour être admissible à une attestation, vous devez avoir la note de passage.",
|
"progress.certificateStatus.notPassingBody": "Pour être admissible à une attestation, vous devez avoir la note de passage.",
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
"courseExit.nextButton.endOfCourse": "Suivant (fin du cours)",
|
"courseExit.nextButton.endOfCourse": "Suivant (fin du cours)",
|
||||||
"courseExit.profileLink": "Profil",
|
"courseExit.profileLink": "Profil",
|
||||||
"courseExit.programs.lastCourse": "Vous avez terminé le dernier cours de {title}!",
|
"courseExit.programs.lastCourse": "Vous avez terminé le dernier cours de {title}!",
|
||||||
"courseCelebration.requestCertificateBodyText": "Pour accéder à votre attestation, demandez-la ci-dessous.",
|
"courseCelebration.requestCertificateBodyText": "Pour accéder à votre certificat, demandez-le ci-dessous.",
|
||||||
"courseCelebration.requestCertificateButton": "Demander une attestation",
|
"courseCelebration.requestCertificateButton": "Demander une attestation",
|
||||||
"courseExit.searchOurCatalogLink": "Rechercher dans notre catalogue",
|
"courseExit.searchOurCatalogLink": "Rechercher dans notre catalogue",
|
||||||
"courseCelebration.shareMessage": "Partagez votre succès sur les réseaux sociaux ou par courriel.",
|
"courseCelebration.shareMessage": "Partagez votre succès sur les réseaux sociaux ou par courriel.",
|
||||||
@@ -414,23 +414,23 @@
|
|||||||
"tours.existingUserTour.launchTourCheckpoint.body": "Nous avons récemment ajouté des nouvelles fonctions à l'expérience de cours. Vous avez besoin d'aide à les trouver? Prenez un tour guidé pour en apprendre plus.",
|
"tours.existingUserTour.launchTourCheckpoint.body": "Nous avons récemment ajouté des nouvelles fonctions à l'expérience de cours. Vous avez besoin d'aide à les trouver? Prenez un tour guidé pour en apprendre plus.",
|
||||||
"tours.button.dismiss": "Rejeter",
|
"tours.button.dismiss": "Rejeter",
|
||||||
"tours.button.next": "Suivant",
|
"tours.button.next": "Suivant",
|
||||||
"tours.button.okay": "D'accord",
|
"tours.button.okay": "Okay",
|
||||||
"tours.button.beginTour": "Commencer la visite guidée",
|
"tours.button.beginTour": "Commencer la visite guidée",
|
||||||
"tours.button.launchTour": "Lancer la visite guidée",
|
"tours.button.launchTour": "Lancer la visite guidée",
|
||||||
"tours.newUserModal.body": "Faisons un tour rapide de {siteName} afin que vous puissiez tirer le meilleur parti de votre cours.",
|
"tours.newUserModal.body": "Faisons un tour rapide de {siteName} afin que vous puissiez tirer le meilleur parti de votre cours.",
|
||||||
"tours.newUserModal.title.welcome": "Bienvenue à votre",
|
"tours.newUserModal.title.welcome": "Bienvenu à votre",
|
||||||
"tours.button.skipForNow": "Ignorer pour l'instant",
|
"tours.button.skipForNow": "Ignorer pour l'instant",
|
||||||
"tours.datesCheckpoint.body": "Dates importantes afin de vous maintenir sur la bonne voie.",
|
"tours.datesCheckpoint.body": "Dates importantes afin de vous maintenir sur la bonne voie.",
|
||||||
"tours.datesCheckpoint.title": "Restez au courant des dates importantes",
|
"tours.datesCheckpoint.title": "Restez au courant des dates importantes",
|
||||||
"tours.outlineCheckpoint.body": "Vous pouvez explorer les sections du cours en utilisant la table des matières ci-dessous.",
|
"tours.outlineCheckpoint.body": "Vous pouvez explorer les sections du cours en utilisant la table des matières ci-dessous.",
|
||||||
"tours.outlineCheckpoint.title": "Suivez le cours!",
|
"tours.outlineCheckpoint.title": "Suivez le cours!",
|
||||||
"tours.tabNavigationCheckpoint.body": "Ces onglets peuvent être utilisés pour accéder aux autres ressources de cours, telles que votre progression, le plan de cours, etc.",
|
"tours.tabNavigationCheckpoint.body": "Ces onglets peuvent être utilisés pour accéder aux autres ressources de cours, tel que votre progression, le plan de cours, etc.",
|
||||||
"tours.tabNavigationCheckpoint.title": "Ressources de cours additionnelles",
|
"tours.tabNavigationCheckpoint.title": "Ressources de cours additionnelles",
|
||||||
"tours.upgradeCheckpoint.body": "Travaillez vers une attestation et obtenez un accès complet au matériel de cours. Mettre à niveau maintenant!",
|
"tours.upgradeCheckpoint.body": "Travaillez vers une attestation et obtenez un accès complet au matériel de cours. Mettre à niveau maintenant!",
|
||||||
"tours.upgradeCheckpoint.title": "Débloquez votre cours",
|
"tours.upgradeCheckpoint.title": "Débloquez votre cours",
|
||||||
"tours.weeklyGoalsCheckpoint.body": "Paramétrer un objectif encourage à compléter votre cours.",
|
"tours.weeklyGoalsCheckpoint.body": "Paramétrer un objectif encourage à compléter votre cours.",
|
||||||
"tours.weeklyGoalsCheckpoint.title": "Paramétrer un objectif de cours",
|
"tours.weeklyGoalsCheckpoint.title": "Paramétrer un objectif de cours",
|
||||||
"tours.newUserModal.title": "{welcome} cours sur {siteName} !",
|
"tours.newUserModal.title": "{welcome} {siteName} au cours!",
|
||||||
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
|
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
|
||||||
"learning.effortEstimation.activities": "{activityCount, plural, one {# activité} many {# activités} other {# activités}}",
|
"learning.effortEstimation.activities": "{activityCount, plural, one {# activité} many {# activités} other {# activités}}",
|
||||||
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} many {# min} other {# min}}",
|
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} many {# min} other {# min}}",
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import NoticesProvider from './generic/notices';
|
|||||||
import PathFixesProvider from './generic/path-fixes';
|
import PathFixesProvider from './generic/path-fixes';
|
||||||
import LiveTab from './course-home/live-tab/LiveTab';
|
import LiveTab from './course-home/live-tab/LiveTab';
|
||||||
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
|
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
|
||||||
import DecodePageRoute from './decode-page-route';
|
|
||||||
|
|
||||||
subscribe(APP_READY, () => {
|
subscribe(APP_READY, () => {
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
@@ -51,28 +50,28 @@ subscribe(APP_READY, () => {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
|
<PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
|
||||||
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
||||||
<DecodePageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
|
<PageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
|
||||||
<DecodePageRoute path="/course/:courseId/home">
|
<PageRoute path="/course/:courseId/home">
|
||||||
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
||||||
<OutlineTab />
|
<OutlineTab />
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
</DecodePageRoute>
|
</PageRoute>
|
||||||
<DecodePageRoute path="/course/:courseId/live">
|
<PageRoute path="/course/:courseId/live">
|
||||||
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
|
<TabContainer tab="live" fetch={fetchLiveTab} slice="courseHome">
|
||||||
<LiveTab />
|
<LiveTab />
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
</DecodePageRoute>
|
</PageRoute>
|
||||||
<DecodePageRoute path="/course/:courseId/dates">
|
<PageRoute path="/course/:courseId/dates">
|
||||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||||
<DatesTab />
|
<DatesTab />
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
</DecodePageRoute>
|
</PageRoute>
|
||||||
<DecodePageRoute path="/course/:courseId/discussion/:path*">
|
<PageRoute path="/course/:courseId/discussion/:path*">
|
||||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||||
<DiscussionTab />
|
<DiscussionTab />
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
</DecodePageRoute>
|
</PageRoute>
|
||||||
<DecodePageRoute
|
<PageRoute
|
||||||
path={[
|
path={[
|
||||||
'/course/:courseId/progress/:targetUserId/',
|
'/course/:courseId/progress/:targetUserId/',
|
||||||
'/course/:courseId/progress',
|
'/course/:courseId/progress',
|
||||||
@@ -87,12 +86,12 @@ subscribe(APP_READY, () => {
|
|||||||
</TabContainer>
|
</TabContainer>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<DecodePageRoute path="/course/:courseId/course-end">
|
<PageRoute path="/course/:courseId/course-end">
|
||||||
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
|
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
|
||||||
<CourseExit />
|
<CourseExit />
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
</DecodePageRoute>
|
</PageRoute>
|
||||||
<DecodePageRoute
|
<PageRoute
|
||||||
path={[
|
path={[
|
||||||
'/course/:courseId/:sequenceId/:unitId',
|
'/course/:courseId/:sequenceId/:unitId',
|
||||||
'/course/:courseId/:sequenceId',
|
'/course/:courseId/:sequenceId',
|
||||||
@@ -137,8 +136,6 @@ initialize({
|
|||||||
TWITTER_URL: process.env.TWITTER_URL || null,
|
TWITTER_URL: process.env.TWITTER_URL || null,
|
||||||
LEGACY_THEME_NAME: process.env.LEGACY_THEME_NAME || null,
|
LEGACY_THEME_NAME: process.env.LEGACY_THEME_NAME || null,
|
||||||
EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null,
|
EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null,
|
||||||
PROCTORED_EXAM_FAQ_URL: process.env.PROCTORED_EXAM_FAQ_URL || null,
|
|
||||||
PROCTORED_EXAM_RULES_URL: process.env.PROCTORED_EXAM_RULES_URL || null,
|
|
||||||
}, 'LearnerAppConfig');
|
}, 'LearnerAppConfig');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,12 +137,10 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
|||||||
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
|
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
|
||||||
courseHomeMetadataUrl = appendBrowserTimezoneToUrl(courseHomeMetadataUrl);
|
courseHomeMetadataUrl = appendBrowserTimezoneToUrl(courseHomeMetadataUrl);
|
||||||
|
|
||||||
const provider = options?.provider || 'legacy';
|
|
||||||
|
|
||||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||||
axiosMock.onGet(discussionConfigUrl).reply(200, { provider });
|
axiosMock.onGet(discussionConfigUrl).reply(200, { provider: 'legacy' });
|
||||||
sequenceMetadata.forEach(metadata => {
|
sequenceMetadata.forEach(metadata => {
|
||||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${metadata.item_id}`;
|
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${metadata.item_id}`;
|
||||||
axiosMock.onGet(sequenceMetadataUrl).reply(200, metadata);
|
axiosMock.onGet(sequenceMetadataUrl).reply(200, metadata);
|
||||||
|
|||||||
Reference in New Issue
Block a user