Compare commits

..

2 Commits

Author SHA1 Message Date
Ben Warzeski
d9f4df7452 hack: local html rendering 2023-04-12 14:25:08 -04:00
Ben Warzeski
c341eb7d22 unit-logging 2023-04-10 11:09:40 -04:00
20 changed files with 14102 additions and 22173 deletions

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -9,13 +9,14 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VER }}
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3

1
.nvmrc
View File

@@ -1 +0,0 @@
18

34502
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,10 +30,10 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-lib-special-exams": "^2.16.1",
"@edx/frontend-platform": "^4.2.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "2.10.0",
"@edx/frontend-platform": "4.1.0",
"@edx/paragon": "20.28.4",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
@@ -45,6 +45,7 @@
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"html-react-parser": "^3.0.15",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
@@ -64,7 +65,7 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.8.27",
"@edx/frontend-build": "^12.4.15",
"@edx/reactifex": "2.1.1",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.5",
@@ -74,7 +75,7 @@
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "29.5.0",
"jest": "27.5.1",
"rosie": "2.1.0"
}
}

View File

@@ -39,186 +39,183 @@ describe('Course Home Service', () => {
afterAll(() => provider.finalize());
describe('When a request to fetch tab is made', () => {
it('returns tab data for a course_id', async () => {
setTimeout(() => {
provider.addInteraction({
state: `Tab data exists for course_id ${courseId}`,
uponReceiving: 'a request to fetch tab',
withRequest: {
method: 'GET',
path: `/api/course_home/course_metadata/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
celebrations: like({
first_section: false,
streak_length_to_celebrate: null,
streak_discount_enabled: false,
}),
course_access: {
has_access: boolean(true),
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
},
course_id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
is_enrolled: boolean(true),
is_self_paced: boolean(false),
is_staff: boolean(true),
number: string('DemoX'),
org: string('edX'),
original_user_is_staff: boolean(true),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
tab_id: 'courseware',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
}),
title: string('Demonstration Course'),
username: string('edx'),
await provider.addInteraction({
state: `Tab data exists for course_id ${courseId}`,
uponReceiving: 'a request to fetch tab',
withRequest: {
method: 'GET',
path: `/api/course_home/course_metadata/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
can_show_upgrade_sock: boolean(false),
verified_mode: like({
access_expiration_date: null,
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
celebrations: like({
first_section: false,
streak_length_to_celebrate: null,
streak_discount_enabled: false,
}),
course_access: {
has_access: boolean(true),
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
},
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
celebrations: {
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountEnabled: false,
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
course_id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
is_enrolled: boolean(true),
is_self_paced: boolean(false),
is_staff: boolean(true),
number: string('DemoX'),
org: string('edX'),
original_user_is_staff: boolean(true),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
tabs: eachLike({
tab_id: 'courseware',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
title: 'Demonstration Course',
username: 'edx',
};
const response = getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
}, 100);
}),
title: string('Demonstration Course'),
username: string('edx'),
},
},
});
const normalizedTabData = {
canShowUpgradeSock: false,
verifiedMode: {
accessExpirationDate: null,
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
celebrations: {
firstSection: false,
streakLengthToCelebrate: null,
streakDiscountEnabled: false,
},
courseAccess: {
hasAccess: true,
errorCode: null,
developerMessage: null,
userMessage: null,
additionalContextUserMessage: null,
userFragment: null,
},
courseId: 'course-v1:edX+DemoX+Demo_Course',
isEnrolled: true,
isMasquerading: false,
isSelfPaced: false,
isStaff: true,
number: 'DemoX',
org: 'edX',
originalUserIsStaff: true,
start: '2013-02-05T05:00:00Z',
tabs: [
{
slug: 'outline',
title: 'Course',
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
},
],
title: 'Demonstration Course',
username: 'edx',
};
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData);
});
});
describe('When a request to fetch dates tab is made', () => {
it('returns course date blocks for a course_id', async () => {
setTimeout(() => {
provider.addInteraction({
state: `course date blocks exist for course_id ${courseId}`,
uponReceiving: 'a request to fetch dates tab',
withRequest: {
method: 'GET',
path: `/api/course_home/dates/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
dates_banner_info: like({
missed_deadlines: false,
content_type_gating_enabled: false,
missed_gated_content: false,
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
course_date_blocks: eachLike({
assignment_type: null,
complete: null,
date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learner_has_access: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extra_info: null,
first_component_block_id: '',
}),
has_ended: boolean(false),
learner_is_full_access: boolean(true),
user_timezone: null,
},
},
});
const camelCaseResponse = {
datesBannerInfo: {
missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
await provider.addInteraction({
state: `course date blocks exist for course_id ${courseId}`,
uponReceiving: 'a request to fetch dates tab',
withRequest: {
method: 'GET',
path: `/api/course_home/dates/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
dates_banner_info: like({
missed_deadlines: false,
content_type_gating_enabled: false,
missed_gated_content: false,
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
course_date_blocks: eachLike({
assignment_type: null,
complete: null,
date: '2013-02-05T05:00:00Z',
dateType: 'verified-upgrade-deadline',
date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
date_type: term({
generate: 'verified-upgrade-deadline',
matcher: dateTypeRegex,
}),
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learnerHasAccess: true,
learner_has_access: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
linkText: 'Upgrade to Verified Certificate',
link_text: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extraInfo: null,
firstComponentBlockId: '',
},
],
hasEnded: false,
learnerIsFullAccess: true,
userTimezone: null,
};
const response = getDatesTabData(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
}, 100);
extra_info: null,
first_component_block_id: '',
}),
has_ended: boolean(false),
learner_is_full_access: boolean(true),
user_timezone: null,
},
},
});
const camelCaseResponse = {
datesBannerInfo: {
missedDeadlines: false,
contentTypeGatingEnabled: false,
missedGatedContent: false,
verifiedUpgradeLink: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
},
courseDateBlocks: [
{
assignmentType: null,
complete: null,
date: '2013-02-05T05:00:00Z',
dateType: 'verified-upgrade-deadline',
description: 'You are still eligible to upgrade to a Verified Certificate! Pursue it to highlight the knowledge and skills you gain in this course.',
learnerHasAccess: true,
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
linkText: 'Upgrade to Verified Certificate',
title: 'Verification Upgrade Deadline',
extraInfo: null,
firstComponentBlockId: '',
},
],
hasEnded: false,
learnerIsFullAccess: true,
userTimezone: null,
};
const response = await getDatesTabData(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
});
});
});

View File

@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
// Click to expand section
userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section
userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
});
it('displays correct icon for complete assignment', async () => {

View File

@@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import { initializeMockApp, waitFor } from '../setupTest';
import { initializeMockApp } from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
@@ -211,7 +211,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -234,7 +234,7 @@ describe('CoursewareContainer', () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -284,7 +284,7 @@ describe('CoursewareContainer', () => {
describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
@@ -359,7 +359,7 @@ describe('CoursewareContainer', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -378,7 +378,7 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -395,7 +395,7 @@ describe('CoursewareContainer', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -411,7 +411,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];

View File

@@ -1,12 +1,12 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { Factory } from 'rosie';
import {
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
} from '../../../setupTest';
import { BookmarkButton } from './index';
import { getBookmarksBaseUrl } from './data/api';
describe('Bookmark Button', () => {
let axiosMock;
@@ -32,8 +32,7 @@ describe('Bookmark Button', () => {
mockData.unitId = nonBookmarkedUnitBlock.id;
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const bookmarkUrl = getBookmarksBaseUrl();
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
axiosMock.onPost(bookmarkUrl).reply(200, { });
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);

View File

@@ -1,13 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
export const getBookmarksBaseUrl = () => `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
export async function createBookmark(usageId) {
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
}
export async function deleteBookmark(usageId) {
const { username } = getAuthenticatedUser();
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
}

View File

@@ -1,5 +1,6 @@
.content-tools {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;

View File

@@ -1,257 +0,0 @@
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);

View File

@@ -0,0 +1,139 @@
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;

View File

@@ -0,0 +1,23 @@
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;

View File

@@ -0,0 +1,12 @@
import HTMLRenderer from './renderers/HTMLRenderer';
export const renderers = {
html: HTMLRenderer,
};
export const FRendlyTypes = Object.keys(renderers);
export default {
renderers,
FRendlyTypes,
};

View File

@@ -0,0 +1,211 @@
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,
};

View File

@@ -0,0 +1,89 @@
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;

View File

@@ -0,0 +1,17 @@
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;

View File

@@ -45,6 +45,25 @@ describe('Courseware Service', () => {
describe('When a request to get a learning sequence outline is made', () => {
it('returns a normalized outline', async () => {
await provider.addInteraction({
state: `Outline exists for course_id ${courseId}`,
uponReceiving: 'a request to get an outline',
withRequest: {
method: 'GET',
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
course_key: string('course-v1:edX+DemoX+Demo_Course'),
title: string('Demo Course'),
outline: {
sections: [],
sequences: {},
},
},
},
});
const normalizedOutline = {
courses: {
'course-v1:edX+DemoX+Demo_Course': {
@@ -57,32 +76,74 @@ describe('Courseware Service', () => {
sections: {},
sequences: {},
};
setTimeout(() => {
provider.addInteraction({
state: `Outline exists for course_id ${courseId}`,
uponReceiving: 'a request to get an outline',
withRequest: {
method: 'GET',
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
course_key: string('course-v1:edX+DemoX+Demo_Course'),
title: string('Demo Course'),
outline: {
sections: [],
sequences: {},
},
},
},
});
const response = getLearningSequencesOutline(courseId);
expect(response).toEqual(normalizedOutline);
}, 100);
const response = await getLearningSequencesOutline(courseId);
expect(response).toEqual(normalizedOutline);
});
it('skips unreleased sequences', async () => {
await provider.addInteraction({
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
uponReceiving: 'a request to get an outline',
withRequest: {
method: 'GET',
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
course_key: string('course-v1:edX+DemoX+Demo_Course'),
title: string('Demo Course'),
outline: like({
sections: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
title: 'Partially released',
sequence_ids: [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
],
effective_start: null,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
title: 'Wholly unreleased',
sequence_ids: [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
],
effective_start: '9999-07-01T17:00:00Z',
},
],
sequences: {
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
title: 'Can access',
accessible: true,
effective_start: '9999-07-01T17:00:00Z',
},
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
title: 'Released and inaccessible',
accessible: false,
effective_start: '2019-07-01T17:00:00Z',
},
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
title: 'Unreleased',
accessible: false,
effective_start: '9999-07-01T17:00:00Z',
},
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
title: 'Still unreleased',
accessible: false,
effective_start: '9999-07-01T17:00:00Z',
},
},
}),
},
},
});
const normalizedOutline = {
courses: {
'course-v1:edX+DemoX+Demo_Course': {
@@ -118,78 +179,120 @@ describe('Courseware Service', () => {
},
},
};
setTimeout(() => {
provider.addInteraction({
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
uponReceiving: 'a request to get an outline',
withRequest: {
method: 'GET',
path: `/api/learning_sequences/v1/course_outline/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
course_key: string('course-v1:edX+DemoX+Demo_Course'),
title: string('Demo Course'),
outline: like({
sections: [
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
title: 'Partially released',
sequence_ids: [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
],
effective_start: null,
},
{
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
title: 'Wholly unreleased',
sequence_ids: [
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
],
effective_start: '9999-07-01T17:00:00Z',
},
],
sequences: {
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
title: 'Can access',
accessible: true,
effective_start: '9999-07-01T17:00:00Z',
},
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
title: 'Released and inaccessible',
accessible: false,
effective_start: '2019-07-01T17:00:00Z',
},
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
title: 'Unreleased',
accessible: false,
effective_start: '9999-07-01T17:00:00Z',
},
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
title: 'Still unreleased',
accessible: false,
effective_start: '9999-07-01T17:00:00Z',
},
},
}),
},
},
});
const response = getLearningSequencesOutline(courseId);
expect(response).toEqual(normalizedOutline);
}, 100);
const response = await getLearningSequencesOutline(courseId);
expect(response).toEqual(normalizedOutline);
});
});
describe('When a request to get course metadata is made', () => {
it('returns normalized course metadata', () => {
it('returns normalized course metadata', async () => {
await provider.addInteraction({
state: `course metadata exists for course_id ${courseId}`,
uponReceiving: 'a request to get course metadata',
withRequest: {
method: 'GET',
path: `/api/courseware/course/${courseId}`,
query: {
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
},
willRespondWith: {
status: 200,
body: {
access_expiration: {
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
masquerading_expired_course: boolean(false),
upgrade_deadline: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
upgrade_url: string('link'),
},
can_show_upgrade_sock: boolean(false),
content_type_gating_enabled: boolean(false),
end: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
enrollment: {
mode: term({
generate: 'audit',
matcher: '^(audit|verified)$',
}),
is_active: boolean(true),
},
enrollment_start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
enrollment_end: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
license: string('all-rights-reserved'),
name: like('Demonstration Course'),
offer: {
code: string('code'),
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
original_price: string('$99'),
discounted_price: string('$99'),
percentage: integer(50),
upgrade_url: string('url'),
},
related_programs: null,
short_description: like(''),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
user_timezone: null,
verified_mode: like({
access_expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
show_calculator: boolean(false),
original_user_is_staff: boolean(true),
is_staff: boolean(true),
course_access: like({
has_access: true,
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
}),
notes: { enabled: boolean(false), visible: boolean(true) },
marketing_url: null,
user_has_passing_grade: boolean(false),
course_exit_page_is_active: boolean(false),
certificate_data: {
cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
},
verify_identity_url: null,
verification_status: string('none'),
linkedin_add_to_profile_url: null,
user_needs_integrity_signature: boolean(false),
},
},
});
const normalizedCourseMetadata = {
accessExpiration: {
expirationDate: '2013-02-05T05:00:00Z',
@@ -234,122 +337,56 @@ describe('Courseware Service', () => {
relatedPrograms: null,
userNeedsIntegritySignature: false,
};
setTimeout(() => {
provider.addInteraction({
state: `course metadata exists for course_id ${courseId}`,
uponReceiving: 'a request to get course metadata',
withRequest: {
method: 'GET',
path: `/api/courseware/course/${courseId}`,
query: {
browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
},
willRespondWith: {
status: 200,
body: {
access_expiration: {
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
masquerading_expired_course: boolean(false),
upgrade_deadline: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
upgrade_url: string('link'),
},
can_show_upgrade_sock: boolean(false),
content_type_gating_enabled: boolean(false),
end: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
enrollment: {
mode: term({
generate: 'audit',
matcher: '^(audit|verified)$',
}),
is_active: boolean(true),
},
enrollment_start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
enrollment_end: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
id: term({
generate: 'course-v1:edX+DemoX+Demo_Course',
matcher: opaqueKeysRegex,
}),
license: string('all-rights-reserved'),
name: like('Demonstration Course'),
offer: {
code: string('code'),
expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
original_price: string('$99'),
discounted_price: string('$99'),
percentage: integer(50),
upgrade_url: string('url'),
},
related_programs: null,
short_description: like(''),
start: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
user_timezone: null,
verified_mode: like({
access_expiration_date: term({
generate: '2013-02-05T05:00:00Z',
matcher: dateRegex,
}),
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}),
show_calculator: boolean(false),
original_user_is_staff: boolean(true),
is_staff: boolean(true),
course_access: like({
has_access: true,
error_code: null,
developer_message: null,
user_message: null,
additional_context_user_message: null,
user_fragment: null,
}),
notes: { enabled: boolean(false), visible: boolean(true) },
marketing_url: null,
user_has_passing_grade: boolean(false),
course_exit_page_is_active: boolean(false),
certificate_data: {
cert_status: string('audit_passing'), cert_web_view_url: null, certificate_available_date: null,
},
verify_identity_url: null,
verification_status: string('none'),
linkedin_add_to_profile_url: null,
user_needs_integrity_signature: boolean(false),
},
},
});
const response = getCourseMetadata(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(normalizedCourseMetadata);
}, 100);
const response = await getCourseMetadata(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(normalizedCourseMetadata);
});
});
describe('When a request to get sequence metadata is made', () => {
it('returns normalized sequence metadata ', () => {
it('returns normalized sequence metadata ', async () => {
await provider.addInteraction({
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
uponReceiving: 'a request to get sequence metadata',
withRequest: {
method: 'GET',
path: `/api/courseware/sequence/${sequenceId}`,
},
willRespondWith: {
status: 200,
body: {
items: eachLike({
content: '',
page_title: 'Pointing on a Picture',
type: 'problem',
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
bookmarked: false,
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
graded: true,
contains_content_type_gated_content: false,
href: '',
}),
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
is_time_limited: boolean(false),
is_proctored: boolean(false),
is_hidden_after_due: boolean(false),
position: null,
tag: boolean('sequential'),
banner_text: null,
save_position: boolean(false),
show_completion: boolean(false),
gated_content: like({
prereq_id: null,
prereq_url: null,
prereq_section_name: null,
gated: false,
gated_section_name: 'Homework - Question Styles',
}),
display_name: boolean('Homework - Question Styles'),
format: boolean('Homework'),
},
},
});
const normalizedSequenceMetadata = {
sequence: {
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
@@ -386,154 +423,102 @@ describe('Courseware Service', () => {
containsContentTypeGatedContent: false,
}],
};
setTimeout(() => {
provider.addInteraction({
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
uponReceiving: 'a request to get sequence metadata',
withRequest: {
method: 'GET',
path: `/api/courseware/sequence/${sequenceId}`,
},
willRespondWith: {
status: 200,
body: {
items: eachLike({
content: '',
page_title: 'Pointing on a Picture',
type: 'problem',
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
bookmarked: false,
path: 'Example Week 1: Getting Started > Homework - Question Styles > Pointing on a Picture',
graded: true,
contains_content_type_gated_content: false,
href: '',
}),
item_id: string('block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions'),
is_time_limited: boolean(false),
is_proctored: boolean(false),
is_hidden_after_due: boolean(false),
position: null,
tag: boolean('sequential'),
banner_text: null,
save_position: boolean(false),
show_completion: boolean(false),
gated_content: like({
prereq_id: null,
prereq_url: null,
prereq_section_name: null,
gated: false,
gated_section_name: 'Homework - Question Styles',
}),
display_name: boolean('Homework - Question Styles'),
format: boolean('Homework'),
},
},
});
const response = getSequenceMetadata(sequenceId);
expect(response).toBeTruthy();
expect(response).toEqual(normalizedSequenceMetadata);
}, 100);
const response = await getSequenceMetadata(sequenceId);
expect(response).toBeTruthy();
expect(response).toEqual(normalizedSequenceMetadata);
});
});
describe('When a request to set sequence position against Unit Index is made', () => {
it('returns if the request was success or failure', async () => {
setTimeout(() => {
provider.addInteraction({
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
uponReceiving: 'a request to set sequence position against activeUnitIndex',
withRequest: {
method: 'POST',
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
await provider.addInteraction({
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
uponReceiving: 'a request to set sequence position against activeUnitIndex',
withRequest: {
method: 'POST',
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
body: { position: 1 }, // Position is 1-indexed on the provider side and 0-indexed in the consumer side.
},
willRespondWith: {
status: 200,
body: {
success: boolean(true),
},
willRespondWith: {
status: 200,
body: {
success: boolean(true),
},
},
});
const response = postSequencePosition(courseId, sequenceId, 0);
expect(response).toBeTruthy();
expect(response).toEqual({ success: true });
}, 100);
},
});
const response = await postSequencePosition(courseId, sequenceId, 0);
expect(response).toBeTruthy();
expect(response).toEqual({ success: true });
});
});
describe('When a request to get completion block is made', () => {
it('returns the completion status', async () => {
setTimeout(() => {
provider.addInteraction({
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
uponReceiving: 'a request to get completion block',
withRequest: {
method: 'POST',
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
body: { usage_key: usageId },
await provider.addInteraction({
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
uponReceiving: 'a request to get completion block',
withRequest: {
method: 'POST',
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
body: { usage_key: usageId },
},
willRespondWith: {
status: 200,
body: {
complete: boolean(true),
},
willRespondWith: {
status: 200,
body: {
complete: boolean(true),
},
},
});
const response = getBlockCompletion(courseId, sequenceId, usageId);
expect(response).toBeTruthy();
expect(response).toEqual(true);
}, 100);
},
});
const response = await getBlockCompletion(courseId, sequenceId, usageId);
expect(response).toBeTruthy();
expect(response).toEqual(true);
});
});
describe('When a request to get resume block is made', () => {
it('returns block id, section id and unit id of the resume block', async () => {
await provider.addInteraction({
state: `Resume block exists for course_id ${courseId}`,
uponReceiving: 'a request to get Resume block',
withRequest: {
method: 'GET',
path: `/api/courseware/resume/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
block_id: string('642fadf46d074aabb637f20af320fb31'),
section_id: string('642fadf46d074aabb637f20af320fb87'),
unit_id: string('642fadf46d074aabb637f20af320fb99'),
},
},
});
const camelCaseResponse = {
blockId: '642fadf46d074aabb637f20af320fb31',
sectionId: '642fadf46d074aabb637f20af320fb87',
unitId: '642fadf46d074aabb637f20af320fb99',
};
setTimeout(() => {
provider.addInteraction({
state: `Resume block exists for course_id ${courseId}`,
uponReceiving: 'a request to get Resume block',
withRequest: {
method: 'GET',
path: `/api/courseware/resume/${courseId}`,
},
willRespondWith: {
status: 200,
body: {
block_id: string('642fadf46d074aabb637f20af320fb31'),
section_id: string('642fadf46d074aabb637f20af320fb87'),
unit_id: string('642fadf46d074aabb637f20af320fb99'),
},
},
});
const response = getResumeBlock(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
}, 100);
const response = await getResumeBlock(courseId);
expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse);
});
});
describe('When a request to send activation email is made', () => {
it('returns status code 200', () => {
setTimeout(() => {
provider.addInteraction({
state: 'A logged-in user may or may not be active',
uponReceiving: 'a request to send activation email',
withRequest: {
method: 'POST',
path: '/api/send_account_activation_email',
},
willRespondWith: {
status: 200,
},
});
const response = sendActivationEmail();
expect(response).toEqual('');
}, 100);
it('returns status code 200', async () => {
await provider.addInteraction({
state: 'A logged-in user may or may not be active',
uponReceiving: 'a request to send activation email',
withRequest: {
method: 'POST',
path: '/api/send_account_activation_email',
},
willRespondWith: {
status: 200,
},
});
const response = await sendActivationEmail();
expect(response).toEqual('');
});
});
});

View File

@@ -233,7 +233,7 @@ describe('Courseware Tour', () => {
// Wait for the page spinner to be removed, such that we can wait for our main
// content to load before making any assertions.
await waitForElementToBeRemoved(screen.getByRole('status'));
return Promise.resolve(container);
return container;
}
describe('when receiving successful course data', () => {