Compare commits
2 Commits
open-relea
...
bw/hackath
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f4df7452 | ||
|
|
c341eb7d22 |
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version-check:
|
version-check:
|
||||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||||
|
|||||||
7
.github/workflows/validate.yml
vendored
7
.github/workflows/validate.yml
vendored
@@ -9,13 +9,14 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [16]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup Nodejs Env
|
|
||||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VER }}
|
node-version: ${{ matrix.node }}
|
||||||
- run: make validate.ci
|
- run: make validate.ci
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
|
|||||||
34502
package-lock.json
generated
34502
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -30,10 +30,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||||
"@edx/frontend-component-footer": "^12.0.0",
|
"@edx/frontend-component-footer": "11.6.3",
|
||||||
"@edx/frontend-component-header": "^4.0.0",
|
"@edx/frontend-component-header": "3.6.4",
|
||||||
"@edx/frontend-lib-special-exams": "^2.16.1",
|
"@edx/frontend-lib-special-exams": "2.10.0",
|
||||||
"@edx/frontend-platform": "^4.2.0",
|
"@edx/frontend-platform": "4.1.0",
|
||||||
"@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,6 +45,7 @@
|
|||||||
"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",
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.1.1",
|
"@edx/browserslist-config": "1.1.1",
|
||||||
"@edx/frontend-build": "^12.8.27",
|
"@edx/frontend-build": "^12.4.15",
|
||||||
"@edx/reactifex": "2.1.1",
|
"@edx/reactifex": "2.1.1",
|
||||||
"@pact-foundation/pact": "9.17.3",
|
"@pact-foundation/pact": "9.17.3",
|
||||||
"@testing-library/jest-dom": "5.16.5",
|
"@testing-library/jest-dom": "5.16.5",
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
"copy-webpack-plugin": "^11.0.0",
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"es-check": "6.2.1",
|
"es-check": "6.2.1",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest": "29.5.0",
|
"jest": "27.5.1",
|
||||||
"rosie": "2.1.0"
|
"rosie": "2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,186 +39,183 @@ describe('Course Home Service', () => {
|
|||||||
afterAll(() => provider.finalize());
|
afterAll(() => provider.finalize());
|
||||||
describe('When a request to fetch tab is made', () => {
|
describe('When a request to fetch tab is made', () => {
|
||||||
it('returns tab data for a course_id', async () => {
|
it('returns tab data for a course_id', async () => {
|
||||||
setTimeout(() => {
|
await provider.addInteraction({
|
||||||
provider.addInteraction({
|
state: `Tab data exists for course_id ${courseId}`,
|
||||||
state: `Tab data exists for course_id ${courseId}`,
|
uponReceiving: 'a request to fetch tab',
|
||||||
uponReceiving: 'a request to fetch tab',
|
withRequest: {
|
||||||
withRequest: {
|
method: 'GET',
|
||||||
method: 'GET',
|
path: `/api/course_home/course_metadata/${courseId}`,
|
||||||
path: `/api/course_home/course_metadata/${courseId}`,
|
},
|
||||||
},
|
willRespondWith: {
|
||||||
willRespondWith: {
|
status: 200,
|
||||||
status: 200,
|
body: {
|
||||||
body: {
|
can_show_upgrade_sock: boolean(false),
|
||||||
can_show_upgrade_sock: boolean(false),
|
verified_mode: like({
|
||||||
verified_mode: like({
|
access_expiration_date: null,
|
||||||
access_expiration_date: null,
|
currency: 'USD',
|
||||||
currency: 'USD',
|
currency_symbol: '$',
|
||||||
currency_symbol: '$',
|
price: 149,
|
||||||
price: 149,
|
sku: '8CF08E5',
|
||||||
sku: '8CF08E5',
|
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
}),
|
||||||
}),
|
celebrations: like({
|
||||||
celebrations: like({
|
first_section: false,
|
||||||
first_section: false,
|
streak_length_to_celebrate: null,
|
||||||
streak_length_to_celebrate: null,
|
streak_discount_enabled: false,
|
||||||
streak_discount_enabled: false,
|
}),
|
||||||
}),
|
course_access: {
|
||||||
course_access: {
|
has_access: boolean(true),
|
||||||
has_access: boolean(true),
|
error_code: null,
|
||||||
error_code: null,
|
developer_message: null,
|
||||||
developer_message: null,
|
user_message: null,
|
||||||
user_message: null,
|
additional_context_user_message: null,
|
||||||
additional_context_user_message: null,
|
user_fragment: 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'),
|
|
||||||
},
|
},
|
||||||
},
|
course_id: term({
|
||||||
});
|
generate: 'course-v1:edX+DemoX+Demo_Course',
|
||||||
const normalizedTabData = {
|
matcher: opaqueKeysRegex,
|
||||||
canShowUpgradeSock: false,
|
}),
|
||||||
verifiedMode: {
|
is_enrolled: boolean(true),
|
||||||
accessExpirationDate: null,
|
is_self_paced: boolean(false),
|
||||||
currency: 'USD',
|
is_staff: boolean(true),
|
||||||
currencySymbol: '$',
|
number: string('DemoX'),
|
||||||
price: 149,
|
org: string('edX'),
|
||||||
sku: '8CF08E5',
|
original_user_is_staff: boolean(true),
|
||||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
start: term({
|
||||||
},
|
generate: '2013-02-05T05:00:00Z',
|
||||||
celebrations: {
|
matcher: dateRegex,
|
||||||
firstSection: false,
|
}),
|
||||||
streakLengthToCelebrate: null,
|
tabs: eachLike({
|
||||||
streakDiscountEnabled: false,
|
tab_id: 'courseware',
|
||||||
},
|
|
||||||
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',
|
title: 'Course',
|
||||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||||
},
|
}),
|
||||||
],
|
title: string('Demonstration Course'),
|
||||||
title: 'Demonstration Course',
|
username: string('edx'),
|
||||||
username: 'edx',
|
},
|
||||||
};
|
},
|
||||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
});
|
||||||
expect(response).toBeTruthy();
|
const normalizedTabData = {
|
||||||
expect(response).toEqual(normalizedTabData);
|
canShowUpgradeSock: false,
|
||||||
}, 100);
|
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', () => {
|
describe('When a request to fetch dates tab is made', () => {
|
||||||
it('returns course date blocks for a course_id', async () => {
|
it('returns course date blocks for a course_id', async () => {
|
||||||
setTimeout(() => {
|
await provider.addInteraction({
|
||||||
provider.addInteraction({
|
state: `course date blocks exist for course_id ${courseId}`,
|
||||||
state: `course date blocks exist for course_id ${courseId}`,
|
uponReceiving: 'a request to fetch dates tab',
|
||||||
uponReceiving: 'a request to fetch dates tab',
|
withRequest: {
|
||||||
withRequest: {
|
method: 'GET',
|
||||||
method: 'GET',
|
path: `/api/course_home/dates/${courseId}`,
|
||||||
path: `/api/course_home/dates/${courseId}`,
|
},
|
||||||
},
|
willRespondWith: {
|
||||||
willRespondWith: {
|
status: 200,
|
||||||
status: 200,
|
body: {
|
||||||
body: {
|
dates_banner_info: like({
|
||||||
dates_banner_info: like({
|
missed_deadlines: false,
|
||||||
missed_deadlines: false,
|
content_type_gating_enabled: false,
|
||||||
content_type_gating_enabled: false,
|
missed_gated_content: false,
|
||||||
missed_gated_content: false,
|
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
verified_upgrade_link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
}),
|
||||||
}),
|
course_date_blocks: eachLike({
|
||||||
course_date_blocks: eachLike({
|
assignment_type: null,
|
||||||
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,
|
|
||||||
complete: null,
|
complete: null,
|
||||||
date: '2013-02-05T05:00:00Z',
|
date: term({
|
||||||
dateType: 'verified-upgrade-deadline',
|
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.',
|
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`,
|
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
linkText: 'Upgrade to Verified Certificate',
|
link_text: 'Upgrade to Verified Certificate',
|
||||||
title: 'Verification Upgrade Deadline',
|
title: 'Verification Upgrade Deadline',
|
||||||
extraInfo: null,
|
extra_info: null,
|
||||||
firstComponentBlockId: '',
|
first_component_block_id: '',
|
||||||
},
|
}),
|
||||||
],
|
has_ended: boolean(false),
|
||||||
hasEnded: false,
|
learner_is_full_access: boolean(true),
|
||||||
learnerIsFullAccess: true,
|
user_timezone: null,
|
||||||
userTimezone: null,
|
},
|
||||||
};
|
},
|
||||||
const response = getDatesTabData(courseId);
|
});
|
||||||
expect(response).toBeTruthy();
|
const camelCaseResponse = {
|
||||||
expect(response).toEqual(camelCaseResponse);
|
datesBannerInfo: {
|
||||||
}, 100);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,21 +38,21 @@ function getBadgeListAndColor(date, intl, item, items) {
|
|||||||
message: messages.today,
|
message: messages.today,
|
||||||
shownForDay: isToday,
|
shownForDay: isToday,
|
||||||
bg: 'bg-warning-300',
|
bg: 'bg-warning-300',
|
||||||
className: 'text-dark',
|
className: 'text-black',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: messages.completed,
|
message: messages.completed,
|
||||||
shownForDay: assignments.length && assignments.every(isComplete),
|
shownForDay: assignments.length && assignments.every(isComplete),
|
||||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||||
bg: 'bg-light-500',
|
bg: 'bg-light-500',
|
||||||
className: 'text-dark',
|
className: 'text-black',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: messages.pastDue,
|
message: messages.pastDue,
|
||||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||||
bg: 'bg-dark-200',
|
bg: 'bg-dark-200',
|
||||||
className: 'text-dark',
|
className: 'text-white',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: messages.dueNext,
|
message: messages.dueNext,
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ const LmsHtmlFragment = ({
|
|||||||
title,
|
title,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const direction = document.documentElement?.getAttribute('dir') || 'ltr';
|
|
||||||
const wholePage = `
|
const wholePage = `
|
||||||
<html dir="${direction}">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||||
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
|
|||||||
|
|
||||||
// Click to expand section
|
// Click to expand section
|
||||||
userEvent.click(expandButton);
|
userEvent.click(expandButton);
|
||||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
// Click to collapse section
|
// Click to collapse section
|
||||||
userEvent.click(expandButton);
|
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 () => {
|
it('displays correct icon for complete assignment', async () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
|
|||||||
|
|
||||||
import { UserMessagesProvider } from '../generic/user-messages';
|
import { UserMessagesProvider } from '../generic/user-messages';
|
||||||
import tabMessages from '../tab-page/messages';
|
import tabMessages from '../tab-page/messages';
|
||||||
import { initializeMockApp, waitFor } from '../setupTest';
|
import { initializeMockApp } from '../setupTest';
|
||||||
|
|
||||||
import CoursewareContainer from './CoursewareContainer';
|
import CoursewareContainer from './CoursewareContainer';
|
||||||
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
||||||
@@ -211,7 +211,7 @@ describe('CoursewareContainer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
history.push(`/course/${courseId}`);
|
history.push(`/course/${courseId}`);
|
||||||
const container = await waitFor(() => loadContainer());
|
const container = await loadContainer();
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -234,7 +234,7 @@ describe('CoursewareContainer', () => {
|
|||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
||||||
|
|
||||||
history.push(`/course/${courseId}`);
|
history.push(`/course/${courseId}`);
|
||||||
const container = await waitFor(() => loadContainer());
|
const container = await loadContainer();
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -284,7 +284,7 @@ describe('CoursewareContainer', () => {
|
|||||||
describe('when the URL does not contain a unit ID', () => {
|
describe('when the URL does not contain a unit ID', () => {
|
||||||
it('should choose a unit within the section\'s first sequence', async () => {
|
it('should choose a unit within the section\'s first sequence', async () => {
|
||||||
setUrl(sectionTree[1].id);
|
setUrl(sectionTree[1].id);
|
||||||
const container = await waitFor(() => loadContainer());
|
const container = await loadContainer();
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container, 2);
|
assertSequenceNavigation(container, 2);
|
||||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
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 () => {
|
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||||
const container = await waitFor(() => loadContainer());
|
const container = await loadContainer();
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -378,7 +378,7 @@ describe('CoursewareContainer', () => {
|
|||||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||||
|
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||||
const container = await waitFor(() => loadContainer());
|
const container = await loadContainer();
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -395,7 +395,7 @@ describe('CoursewareContainer', () => {
|
|||||||
|
|
||||||
it('should load the specified unit', async () => {
|
it('should load the specified unit', async () => {
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||||
const container = await waitFor(() => loadContainer());
|
const container = await loadContainer();
|
||||||
|
|
||||||
assertLoadedHeader(container);
|
assertLoadedHeader(container);
|
||||||
assertSequenceNavigation(container);
|
assertSequenceNavigation(container);
|
||||||
@@ -411,7 +411,7 @@ describe('CoursewareContainer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
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 sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||||
const sequenceNextButton = sequenceNavButtons[4];
|
const sequenceNextButton = sequenceNavButtons[4];
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import {
|
import {
|
||||||
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
|
||||||
} from '../../../setupTest';
|
} from '../../../setupTest';
|
||||||
import { BookmarkButton } from './index';
|
import { BookmarkButton } from './index';
|
||||||
import { getBookmarksBaseUrl } from './data/api';
|
|
||||||
|
|
||||||
describe('Bookmark Button', () => {
|
describe('Bookmark Button', () => {
|
||||||
let axiosMock;
|
let axiosMock;
|
||||||
@@ -32,8 +32,7 @@ describe('Bookmark Button', () => {
|
|||||||
mockData.unitId = nonBookmarkedUnitBlock.id;
|
mockData.unitId = nonBookmarkedUnitBlock.id;
|
||||||
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
const bookmarkUrl = getBookmarksBaseUrl();
|
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||||
|
|
||||||
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
axiosMock.onPost(bookmarkUrl).reply(200, { });
|
||||||
|
|
||||||
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
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) {
|
export async function createBookmark(usageId) {
|
||||||
return getAuthenticatedHttpClient().post(getBookmarksBaseUrl(), { usage_id: usageId });
|
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBookmark(usageId) {
|
export async function deleteBookmark(usageId) {
|
||||||
const { username } = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
return getAuthenticatedHttpClient().delete(`${getBookmarksBaseUrl()}${username},${usageId}/`);
|
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.content-tools {
|
.content-tools {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|||||||
@@ -144,29 +144,27 @@ const Sequence = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultContent = (
|
const defaultContent = (
|
||||||
<div className="sequence-container d-inline-flex flex-row w-100">
|
<div className="sequence-container d-inline-flex flex-row">
|
||||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||||
<div className="sequence-navigation-container">
|
<SequenceNavigation
|
||||||
<SequenceNavigation
|
sequenceId={sequenceId}
|
||||||
sequenceId={sequenceId}
|
unitId={unitId}
|
||||||
unitId={unitId}
|
className="mb-4"
|
||||||
className="mb-4"
|
nextSequenceHandler={() => {
|
||||||
nextSequenceHandler={() => {
|
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
handleNext();
|
||||||
handleNext();
|
}}
|
||||||
}}
|
onNavigate={(destinationUnitId) => {
|
||||||
onNavigate={(destinationUnitId) => {
|
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
handleNavigate(destinationUnitId);
|
||||||
handleNavigate(destinationUnitId);
|
}}
|
||||||
}}
|
previousSequenceHandler={() => {
|
||||||
previousSequenceHandler={() => {
|
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
handlePrevious();
|
||||||
handlePrevious();
|
}}
|
||||||
}}
|
goToCourseExitPage={() => goToCourseExitPage()}
|
||||||
goToCourseExitPage={() => goToCourseExitPage()}
|
/>
|
||||||
/>
|
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="unit-container flex-grow-1">
|
<div className="unit-container flex-grow-1">
|
||||||
<SequenceContent
|
<SequenceContent
|
||||||
|
|||||||
@@ -1,269 +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 [windowTopOffset, setWindowTopOffset] = useState(null);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// We observe exit from the video xblock full screen mode
|
|
||||||
// and do page scroll to the previously saved scroll position
|
|
||||||
if (windowTopOffset !== null) {
|
|
||||||
window.scrollTo(0, Number(windowTopOffset));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
|
||||||
setHasLoaded(true);
|
|
||||||
if (onLoaded) {
|
|
||||||
onLoaded();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type === 'plugin.modal') {
|
|
||||||
payload.open = true;
|
|
||||||
setModalOptions(payload);
|
|
||||||
} else if (type === 'plugin.videoFullScreen') {
|
|
||||||
// We listen for this message from LMS to know when we need to
|
|
||||||
// save or reset scroll position on toggle video xblock full screen mode.
|
|
||||||
setWindowTopOffset(payload.open ? window.scrollY : null);
|
|
||||||
} 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, setWindowTopOffset, windowTopOffset]);
|
|
||||||
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);
|
|
||||||
@@ -129,21 +129,6 @@ describe('Unit', () => {
|
|||||||
expect(window.scrollY === testMessageWithOffset.offset);
|
expect(window.scrollY === testMessageWithOffset.offset);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scrolls page on MessagaeEvent when receiving videoFullScreen state', async () => {
|
|
||||||
// Set message to constain video full screen data.
|
|
||||||
const defaultTopOffset = 800;
|
|
||||||
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 500 } };
|
|
||||||
const testMessageWithFullscreenState = (isOpen) => ({ type: 'plugin.videoFullScreen', payload: { open: isOpen } });
|
|
||||||
render(<Unit {...mockData} />);
|
|
||||||
Object.defineProperty(window, 'scrollY', { value: defaultTopOffset, writable: true });
|
|
||||||
window.postMessage(testMessageWithFullscreenState(true), '*');
|
|
||||||
window.postMessage(testMessageWithFullscreenState(false), '*');
|
|
||||||
window.postMessage(testMessageWithOtherHeight, '*');
|
|
||||||
|
|
||||||
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalledTimes(1)));
|
|
||||||
expect(window.scrollY === defaultTopOffset);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores MessageEvent with unhandled type', async () => {
|
it('ignores MessageEvent with unhandled type', async () => {
|
||||||
// Clone message and set different type.
|
// Clone message and set different type.
|
||||||
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };
|
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };
|
||||||
|
|||||||
139
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
139
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal 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;
|
||||||
23
src/courseware/course/sequence/Unit/LocalIFrame.jsx
Normal file
23
src/courseware/course/sequence/Unit/LocalIFrame.jsx
Normal 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;
|
||||||
12
src/courseware/course/sequence/Unit/constants.js
Normal file
12
src/courseware/course/sequence/Unit/constants.js
Normal 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,
|
||||||
|
};
|
||||||
211
src/courseware/course/sequence/Unit/hooks.js
Normal file
211
src/courseware/course/sequence/Unit/hooks.js
Normal 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,
|
||||||
|
};
|
||||||
89
src/courseware/course/sequence/Unit/index.jsx
Normal file
89
src/courseware/course/sequence/Unit/index.jsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -80,7 +80,7 @@ const SequenceNavigation = ({
|
|||||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||||
|
|
||||||
return sequenceStatus === LOADED && (
|
return sequenceStatus === LOADED && (
|
||||||
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}>
|
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
|
||||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
||||||
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
|
||||||
import SidebarContext from './SidebarContext';
|
import SidebarContext from './SidebarContext';
|
||||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||||
|
|
||||||
@@ -9,9 +8,6 @@ const SidebarTriggers = () => {
|
|||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
currentSidebar,
|
currentSidebar,
|
||||||
} = useContext(SidebarContext);
|
} = useContext(SidebarContext);
|
||||||
|
|
||||||
const isMobileView = useWindowSize().width < breakpoints.small.minWidth;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex ml-auto">
|
<div className="d-flex ml-auto">
|
||||||
{SIDEBAR_ORDER.map((sidebarId) => {
|
{SIDEBAR_ORDER.map((sidebarId) => {
|
||||||
@@ -19,7 +15,7 @@ const SidebarTriggers = () => {
|
|||||||
const isActive = sidebarId === currentSidebar;
|
const isActive = sidebarId === currentSidebar;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames({ 'mt-3': !isMobileView, 'border-primary-700': isActive })}
|
className={classNames('mt-3', { 'border-primary-700': isActive })}
|
||||||
style={{ borderBottom: isActive ? '2px solid' : null }}
|
style={{ borderBottom: isActive ? '2px solid' : null }}
|
||||||
key={sidebarId}
|
key={sidebarId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SidebarTriggerBase = ({
|
|||||||
children,
|
children,
|
||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex notification-btn"
|
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ describe('Courseware Service', () => {
|
|||||||
|
|
||||||
describe('When a request to get a learning sequence outline is made', () => {
|
describe('When a request to get a learning sequence outline is made', () => {
|
||||||
it('returns a normalized outline', async () => {
|
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 = {
|
const normalizedOutline = {
|
||||||
courses: {
|
courses: {
|
||||||
'course-v1:edX+DemoX+Demo_Course': {
|
'course-v1:edX+DemoX+Demo_Course': {
|
||||||
@@ -57,32 +76,74 @@ describe('Courseware Service', () => {
|
|||||||
sections: {},
|
sections: {},
|
||||||
sequences: {},
|
sequences: {},
|
||||||
};
|
};
|
||||||
setTimeout(() => {
|
const response = await getLearningSequencesOutline(courseId);
|
||||||
provider.addInteraction({
|
expect(response).toEqual(normalizedOutline);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips unreleased sequences', async () => {
|
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 = {
|
const normalizedOutline = {
|
||||||
courses: {
|
courses: {
|
||||||
'course-v1:edX+DemoX+Demo_Course': {
|
'course-v1:edX+DemoX+Demo_Course': {
|
||||||
@@ -118,78 +179,120 @@ describe('Courseware Service', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setTimeout(() => {
|
const response = await getLearningSequencesOutline(courseId);
|
||||||
provider.addInteraction({
|
expect(response).toEqual(normalizedOutline);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get course metadata is made', () => {
|
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 = {
|
const normalizedCourseMetadata = {
|
||||||
accessExpiration: {
|
accessExpiration: {
|
||||||
expirationDate: '2013-02-05T05:00:00Z',
|
expirationDate: '2013-02-05T05:00:00Z',
|
||||||
@@ -234,122 +337,56 @@ describe('Courseware Service', () => {
|
|||||||
relatedPrograms: null,
|
relatedPrograms: null,
|
||||||
userNeedsIntegritySignature: false,
|
userNeedsIntegritySignature: false,
|
||||||
};
|
};
|
||||||
setTimeout(() => {
|
const response = await getCourseMetadata(courseId);
|
||||||
provider.addInteraction({
|
expect(response).toBeTruthy();
|
||||||
state: `course metadata exists for course_id ${courseId}`,
|
expect(response).toEqual(normalizedCourseMetadata);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get sequence metadata is made', () => {
|
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 = {
|
const normalizedSequenceMetadata = {
|
||||||
sequence: {
|
sequence: {
|
||||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||||
@@ -386,154 +423,102 @@ describe('Courseware Service', () => {
|
|||||||
containsContentTypeGatedContent: false,
|
containsContentTypeGatedContent: false,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
setTimeout(() => {
|
const response = await getSequenceMetadata(sequenceId);
|
||||||
provider.addInteraction({
|
expect(response).toBeTruthy();
|
||||||
state: `sequence metadata data exists for sequence_id ${sequenceId}`,
|
expect(response).toEqual(normalizedSequenceMetadata);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to set sequence position against Unit Index is made', () => {
|
describe('When a request to set sequence position against Unit Index is made', () => {
|
||||||
it('returns if the request was success or failure', async () => {
|
it('returns if the request was success or failure', async () => {
|
||||||
setTimeout(() => {
|
await provider.addInteraction({
|
||||||
provider.addInteraction({
|
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
||||||
state: `sequence position data exists for course_id ${courseId}, sequence_id ${sequenceId} and activeUnitIndex 0`,
|
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
||||||
uponReceiving: 'a request to set sequence position against activeUnitIndex',
|
withRequest: {
|
||||||
withRequest: {
|
method: 'POST',
|
||||||
method: 'POST',
|
path: `/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`,
|
||||||
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.
|
||||||
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: {
|
const response = await postSequencePosition(courseId, sequenceId, 0);
|
||||||
success: boolean(true),
|
expect(response).toBeTruthy();
|
||||||
},
|
expect(response).toEqual({ success: true });
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = postSequencePosition(courseId, sequenceId, 0);
|
|
||||||
expect(response).toBeTruthy();
|
|
||||||
expect(response).toEqual({ success: true });
|
|
||||||
}, 100);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get completion block is made', () => {
|
describe('When a request to get completion block is made', () => {
|
||||||
it('returns the completion status', async () => {
|
it('returns the completion status', async () => {
|
||||||
setTimeout(() => {
|
await provider.addInteraction({
|
||||||
provider.addInteraction({
|
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
||||||
state: `completion block data exists for course_id ${courseId}, sequence_id ${sequenceId} and usageId ${usageId}`,
|
uponReceiving: 'a request to get completion block',
|
||||||
uponReceiving: 'a request to get completion block',
|
withRequest: {
|
||||||
withRequest: {
|
method: 'POST',
|
||||||
method: 'POST',
|
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
||||||
path: `/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`,
|
body: { usage_key: usageId },
|
||||||
body: { usage_key: usageId },
|
},
|
||||||
|
willRespondWith: {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
complete: boolean(true),
|
||||||
},
|
},
|
||||||
willRespondWith: {
|
},
|
||||||
status: 200,
|
});
|
||||||
body: {
|
const response = await getBlockCompletion(courseId, sequenceId, usageId);
|
||||||
complete: boolean(true),
|
expect(response).toBeTruthy();
|
||||||
},
|
expect(response).toEqual(true);
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = getBlockCompletion(courseId, sequenceId, usageId);
|
|
||||||
expect(response).toBeTruthy();
|
|
||||||
expect(response).toEqual(true);
|
|
||||||
}, 100);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to get resume block is made', () => {
|
describe('When a request to get resume block is made', () => {
|
||||||
it('returns block id, section id and unit id of the resume block', async () => {
|
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 = {
|
const camelCaseResponse = {
|
||||||
blockId: '642fadf46d074aabb637f20af320fb31',
|
blockId: '642fadf46d074aabb637f20af320fb31',
|
||||||
sectionId: '642fadf46d074aabb637f20af320fb87',
|
sectionId: '642fadf46d074aabb637f20af320fb87',
|
||||||
unitId: '642fadf46d074aabb637f20af320fb99',
|
unitId: '642fadf46d074aabb637f20af320fb99',
|
||||||
};
|
};
|
||||||
setTimeout(() => {
|
const response = await getResumeBlock(courseId);
|
||||||
provider.addInteraction({
|
expect(response).toBeTruthy();
|
||||||
state: `Resume block exists for course_id ${courseId}`,
|
expect(response).toEqual(camelCaseResponse);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When a request to send activation email is made', () => {
|
describe('When a request to send activation email is made', () => {
|
||||||
it('returns status code 200', () => {
|
it('returns status code 200', async () => {
|
||||||
setTimeout(() => {
|
await provider.addInteraction({
|
||||||
provider.addInteraction({
|
state: 'A logged-in user may or may not be active',
|
||||||
state: 'A logged-in user may or may not be active',
|
uponReceiving: 'a request to send activation email',
|
||||||
uponReceiving: 'a request to send activation email',
|
withRequest: {
|
||||||
withRequest: {
|
method: 'POST',
|
||||||
method: 'POST',
|
path: '/api/send_account_activation_email',
|
||||||
path: '/api/send_account_activation_email',
|
},
|
||||||
},
|
willRespondWith: {
|
||||||
willRespondWith: {
|
status: 200,
|
||||||
status: 200,
|
},
|
||||||
},
|
});
|
||||||
});
|
const response = await sendActivationEmail();
|
||||||
const response = sendActivationEmail();
|
expect(response).toEqual('');
|
||||||
expect(response).toEqual('');
|
|
||||||
}, 100);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const invisibleStyle = {
|
|||||||
left: 0,
|
left: 0,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
maxWidth: '100%',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,8 +22,9 @@
|
|||||||
// An additional Font Awesome stylesheet is imported by Braze in
|
// An additional Font Awesome stylesheet is imported by Braze in
|
||||||
// stage/production but not devstack.
|
// stage/production but not devstack.
|
||||||
.upgrade-notification-ul.fa-ul {
|
.upgrade-notification-ul.fa-ul {
|
||||||
padding: 0.875rem 1.25rem 0;
|
padding-left: 1.25rem;
|
||||||
margin: 0 0 1rem 2.5rem;
|
padding-top: 0.875rem;
|
||||||
|
padding-right: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upgrade-notification-text {
|
.upgrade-notification-text {
|
||||||
|
|||||||
@@ -77,10 +77,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pgn__menu-select .pgn__menu-select-popup {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sequence-container {
|
.sequence-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -90,6 +86,7 @@
|
|||||||
// On mobile, the unit container will be responsible
|
// On mobile, the unit container will be responsible
|
||||||
// for container padding.
|
// for container padding.
|
||||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||||
|
width: 100%;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -102,24 +99,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sequence-navigation-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-btn {
|
|
||||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
|
||||||
height: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sequence-navigation {
|
.sequence-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||||
margin: -1px -1px 0;
|
margin: -1px -1px 0;
|
||||||
@@ -187,10 +168,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sequence-navigation-tabs {
|
.sequence-navigation-tabs {
|
||||||
overflow: auto;
|
|
||||||
.btn {
|
.btn {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
min-width: 3rem;
|
min-width: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ describe('Courseware Tour', () => {
|
|||||||
// Wait for the page spinner to be removed, such that we can wait for our main
|
// Wait for the page spinner to be removed, such that we can wait for our main
|
||||||
// content to load before making any assertions.
|
// content to load before making any assertions.
|
||||||
await waitForElementToBeRemoved(screen.getByRole('status'));
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
||||||
return Promise.resolve(container);
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('when receiving successful course data', () => {
|
describe('when receiving successful course data', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user