Compare commits

..

2 Commits

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

1
.env
View File

@@ -46,4 +46,3 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,7 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -46,6 +46,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -45,4 +45,3 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'

View File

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

View File

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

2
.gitignore vendored
View File

@@ -1,8 +1,6 @@
.DS_Store
.eslintcache
.idea
*.swp
*.swo
node_modules
npm-debug.log
coverage

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

@@ -1,7 +1,6 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
@@ -43,24 +42,9 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -9,12 +9,6 @@ module.exports = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000,
testEnvironment: 'jsdom'
});

39111
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,45 +30,44 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.14.0",
"@edx/frontend-lib-special-exams": "2.23.2",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "2.10.0",
"@edx/frontend-platform": "4.1.0",
"@edx/paragon": "20.28.4",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4",
"@popperjs/core": "2.11.8",
"@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.6",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"js-cookie": "3.0.5",
"html-react-parser": "^3.0.15",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"query-string": "7.1.3",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.8",
"reselect": "4.1.7",
"truncate-html": "1.0.4",
"util": "0.12.5"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "^12.9.10",
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^11.0.2",
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.4.15",
"@edx/reactifex": "2.1.1",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
@@ -76,7 +75,7 @@
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "29.5.0",
"jest": "27.5.1",
"rosie": "2.1.0"
}
}

View File

@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
<Alert variant="info" icon={Info}>
<strong>
<FormattedMessage
id="learning.outline.alert.start.long"
id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
@@ -88,7 +88,7 @@ const CourseStartAlert = ({ payload }) => {
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.start.calendar"
id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/>

View File

@@ -1,33 +0,0 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -28,7 +28,6 @@ Factory.define('outlineTabData')
upgrade_url: `${host}/dashboard`,
}))
.attrs({
course_access_redirect: false,
has_scheduled_content: null,
access_expiration: null,
can_show_upgrade_sock: false,

View File

@@ -18,9 +18,6 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
@@ -339,9 +336,6 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
@@ -538,9 +532,6 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {

View File

@@ -204,18 +204,12 @@ export async function getDatesTabData(courseId) {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const httpErrorStatus = error?.response?.status;
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
}
@@ -265,7 +259,7 @@ export async function getProgressTabData(courseId, targetUserId) {
return camelCasedData;
} catch (error) {
const httpErrorStatus = error?.response?.status;
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
@@ -275,12 +269,6 @@ export async function getProgressTabData(courseId, targetUserId) {
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
}
@@ -334,20 +322,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now();
let tabData;
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
const tabData = await getAuthenticatedHttpClient().get(url);
const responseTime = Date.now();
const {

View File

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

View File

@@ -21,18 +21,6 @@ describe('Data layer integration tests', () => {
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeAccessDeniedMetadata = Factory.build(
'courseHomeMetadata',
{
id: courseId,
course_access: {
has_access: false,
error_code: 'bad codes',
additional_context_user_message: 'your Codes Are BAD',
},
},
);
let store;
beforeEach(() => {
@@ -67,40 +55,16 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
expect(state).toMatchSnapshot();
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 401 || errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(outlineUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
@@ -111,6 +75,8 @@ describe('Data layer integration tests', () => {
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
@@ -118,31 +84,8 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
expect(state).toMatchSnapshot();
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchProgressTab', () => {
@@ -170,14 +113,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
expect(state).toMatchSnapshot();
});
it('Should handle the url including a targetUserId', async () => {
@@ -193,19 +129,6 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2);
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(progressUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
});
describe('Test saveCourseGoal', () => {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
@@ -32,16 +32,11 @@ describe('DatesTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
)}
/>
</Routes>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -2,20 +2,21 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom';
import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
const DiscussionTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
const navigate = useNavigate();
const history = useHistory();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
navigate(`${basePath}/${payload.path}`);
history.push(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;

View File

@@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { Route } from 'react-router';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
@@ -30,16 +30,11 @@ describe('DiscussionTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
<Route
path="/course/:courseId/discussion"
element={(
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
)}
/>
</Routes>
<Route path="/course/:courseId/discussion">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);

View File

@@ -1,9 +1,7 @@
import React from 'react';
import {
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
@@ -26,16 +24,13 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store} wrapWithRouter={false}>
<AppProvider store={store}>
<UserMessagesProvider>
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
</UserMessagesProvider>
</AppProvider>
);
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
});
it('starts with a spinner', () => {

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
@@ -66,7 +67,6 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = {
org_key: org,
@@ -115,10 +115,8 @@ const OutlineTab = ({ intl }) => {
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
navigate({
pathname: location.pathname,
search: `?${currentParams.toString()}`,
replace: true,
history.replace({
search: currentParams.toString(),
});
}
}, [location.search]);

View File

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

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { createSelector } from '@reduxjs/toolkit';
import { defaultMemoize as memoize } from 'reselect';
@@ -16,46 +17,45 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
import withParamsAndNavigation from './utils';
// Look at where this is called in componentDidUpdate for more info about its usage
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) {
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
} else if (firstSequenceId) {
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
history.replace(`/course/${courseId}/${firstSequenceId}`);
}
});
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
navigate(`/course/${courseId}/${unitId}`, { replace: true });
history.replace(`/course/${courseId}/${unitId}`);
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) {
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
// Otherwise, just go to the course root, letting the resume redirect take care of things.
} else {
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize(
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
@@ -64,62 +64,60 @@ const checkUnitToSequenceUnitRedirect = memoize(
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
} else {
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
}
},
() => { // error case
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
navigate(`/course/${courseId}`, { replace: true });
history.replace(`/course/${courseId}`);
}
}
},
);
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
}
}
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
(courseId, sequenceStatus, sequence, unitId, navigate) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus !== 'loaded' || !sequence.id) {
return;
}
const hasUnits = sequence.unitIds?.length > 0;
const hasUnits = sequence.unitIds?.length > 0;
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true });
} else {
if (unitId === 'first') {
if (hasUnits) {
const firstUnitId = sequence.unitIds[0];
history.replace(`/course/${courseId}/${sequence.id}/${firstUnitId}`);
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true });
} else {
// No units... go to general sequence page
navigate(`/course/${courseId}/${sequence.id}`, { replace: true });
}
history.replace(`/course/${courseId}/${sequence.id}`);
}
},
);
} else if (unitId === 'last') {
if (hasUnits) {
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
} else {
// No units... go to general sequence page
history.replace(`/course/${courseId}/${sequence.id}`);
}
}
});
class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((unitId) => {
@@ -147,8 +145,12 @@ class CoursewareContainer extends Component {
componentDidMount() {
const {
routeCourseId,
routeSequenceId,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
@@ -165,10 +167,13 @@ class CoursewareContainer extends Component {
sequence,
firstSequenceId,
sectionViaSequenceId,
routeCourseId,
routeSequenceId,
routeUnitId,
navigate,
match: {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
} = this.props;
// Load data whenever the course or sequence ID changes.
@@ -197,7 +202,7 @@ class CoursewareContainer extends Component {
// Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
// based on sequence/unit where user was last active.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
// Check section-unit to unit redirect:
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
@@ -210,54 +215,60 @@ class CoursewareContainer extends Component {
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
// and `checkUnitToSequenceUnitRedirect`.
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check section to sequence redirect:
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
// by redirecting to the first sequence within the section.
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
sequenceId, sectionViaSequenceId, routeUnitId, navigate
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
));
// Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
// Check sequence-unit marker to sequence-unit redirect:
// /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId
// /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the first or last unit in the sequence.
// "Sequence unit marker" is an invented term used only in this component.
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
}
handleUnitNavigationClick = () => {
handleUnitNavigationClick = (nextUnitId) => {
const {
courseId,
sequenceId,
routeUnitId,
courseId, sequenceId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
};
handleNextSequenceClick = () => {
const {
course,
courseId,
nextSequence,
sequence,
sequenceId,
} = this.props;
if (nextSequence !== null) {
history.push(`/course/${courseId}/${nextSequence.id}/first`);
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
handleNextSectionCelebration(sequenceId, nextSequence.id);
@@ -265,14 +276,23 @@ class CoursewareContainer extends Component {
}
};
handlePreviousSequenceClick = () => {};
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
history.push(`/course/${courseId}/${previousSequence.id}/last`);
}
};
render() {
const {
courseStatus,
courseId,
sequenceId,
routeUnitId,
match: {
params: {
unitId: routeUnitId,
},
},
} = this.props;
return (
@@ -315,9 +335,13 @@ const courseShape = PropTypes.shape({
});
CoursewareContainer.propTypes = {
routeCourseId: PropTypes.string.isRequired,
routeSequenceId: PropTypes.string,
routeUnitId: PropTypes.string,
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
courseId: PropTypes.string,
sequenceId: PropTypes.string,
firstSequenceId: PropTypes.string,
@@ -333,14 +357,11 @@ CoursewareContainer.propTypes = {
checkBlockCompletion: PropTypes.func.isRequired,
fetchCourse: PropTypes.func.isRequired,
fetchSequence: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
};
CoursewareContainer.defaultProps = {
courseId: null,
sequenceId: null,
routeSequenceId: null,
routeUnitId: null,
firstSequenceId: null,
nextSequence: null,
previousSequence: null,
@@ -455,4 +476,4 @@ export default connect(mapStateToProps, {
saveSequencePosition,
fetchCourse,
fetchSequence,
})(withParamsAndNavigation(CoursewareContainer));
})(CoursewareContainer);

View File

@@ -5,16 +5,13 @@ import { waitForElementToBeRemoved, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom/extend-expect';
import { render, screen } from '@testing-library/react';
import React from 'react';
import {
BrowserRouter, MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Route, Switch } from 'react-router';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import { initializeMockApp, waitFor } from '../setupTest';
import { DECODE_ROUTES } from '../constants';
import { initializeMockApp } from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
@@ -83,16 +80,18 @@ describe('CoursewareContainer', () => {
store = initializeStore();
component = (
<AppProvider store={store} wrapWithRouter={false}>
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
{DECODE_ROUTES.COURSEWARE.map((route) => (
<Route
path={route}
element={<CoursewareContainer />}
/>
))}
</Routes>
<Switch>
<Route
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
]}
component={CoursewareContainer}
/>
</Switch>
</UserMessagesProvider>
</AppProvider>
);
@@ -152,7 +151,7 @@ describe('CoursewareContainer', () => {
}
async function loadContainer() {
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
const { container } = render(component);
// Wait for the page spinner to be removed, such that we can wait for our main
// content to load before making any assertions.
await waitForElementToBeRemoved(screen.getByRole('status'));
@@ -161,7 +160,7 @@ describe('CoursewareContainer', () => {
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
render(component);
const spinner = screen.getByRole('status');
@@ -186,7 +185,7 @@ describe('CoursewareContainer', () => {
function assertSequenceNavigation(container, expectedUnitCount = 3) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
@@ -212,7 +211,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -235,7 +234,7 @@ describe('CoursewareContainer', () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -285,7 +284,7 @@ describe('CoursewareContainer', () => {
describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
@@ -360,7 +359,7 @@ describe('CoursewareContainer', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -379,7 +378,7 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -396,7 +395,7 @@ describe('CoursewareContainer', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -412,12 +411,12 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await waitFor(() => loadContainer());
const container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];
expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNextButton);
fireEvent.click(sequenceNavButtons[4]);
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
});

View File

@@ -1,44 +1,56 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Switch, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageWrap } from '@edx/frontend-platform/react';
import { PageRoute } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading';
import DecodePageRoute from '../decode-page-route';
import { DECODE_ROUTES, REDIRECT_MODES, ROUTES } from '../constants';
import RedirectPage from './RedirectPage';
const CoursewareRedirectLandingPage = () => (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
const CoursewareRedirectLandingPage = () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
<PageLoading srMessage={(
<FormattedMessage
id="learn.redirect.interstitial.message"
description="The screen-reader message when a page is about to redirect"
defaultMessage="Redirecting..."
/>
)}
/>
/>
<Routes>
<Route
path={DECODE_ROUTES.REDIRECT_SURVEY}
element={<DecodePageRoute><RedirectPage pattern="/courses/:courseId/survey" mode={REDIRECT_MODES.SURVEY_REDIRECT} /></DecodePageRoute>}
/>
<Route
path={ROUTES.DASHBOARD}
element={<PageWrap><RedirectPage pattern="/dashboard" mode={REDIRECT_MODES.DASHBOARD_REDIRECT} /></PageWrap>}
/>
<Route
path={ROUTES.CONSENT}
element={<PageWrap><RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} /></PageWrap>}
/>
<Route
path={DECODE_ROUTES.REDIRECT_HOME}
element={<DecodePageRoute><RedirectPage pattern="/course/:courseId/home" mode={REDIRECT_MODES.HOME_REDIRECT} /></DecodePageRoute>}
/>
</Routes>
</div>
);
<Switch>
<DecodePageRoute
path={`${path}/survey/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
}}
/>
<PageRoute
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
}}
/>
<PageRoute
path={`${path}/consent/`}
render={({ location }) => {
const { consentPath } = queryString.parse(location.search);
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<DecodePageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
}}
/>
</Switch>
</div>
);
};
export default CoursewareRedirectLandingPage;

View File

@@ -1,12 +1,19 @@
import React from 'react';
import { MemoryRouter as Router } from 'react-router-dom';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn();
jest.mock('@edx/frontend-platform/analytics');
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useRouteMatch: () => ({
path: '/redirect',
}),
}));
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
@@ -16,8 +23,12 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
});
render(
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
<Router history={history}>
<CoursewareRedirectLandingPage />
</Router>,
);
@@ -26,8 +37,12 @@ describe('CoursewareRedirectLandingPage', () => {
});
it('Redirects to correct consent URL', () => {
const history = createMemoryHistory({
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
});
render(
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
<Router history={history}>
<CoursewareRedirectLandingPage />
</Router>,
);

View File

@@ -1,45 +0,0 @@
import PropTypes from 'prop-types';
import {
generatePath, useParams, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import queryString from 'query-string';
import { REDIRECT_MODES } from '../constants';
const RedirectPage = ({
pattern, mode,
}) => {
const { courseId } = useParams();
const location = useLocation();
const { consentPath } = queryString.parse(location?.search);
const BASE_URL = getConfig().LMS_BASE_URL;
switch (mode) {
case REDIRECT_MODES.DASHBOARD_REDIRECT:
global.location.assign(`${BASE_URL}${pattern}${location?.search}`);
break;
case REDIRECT_MODES.CONSENT_REDIRECT:
global.location.assign(`${BASE_URL}${consentPath}`);
break;
case REDIRECT_MODES.HOME_REDIRECT:
global.location.assign(generatePath(pattern, { courseId }));
break;
default:
global.location.assign(`${BASE_URL}${generatePath(pattern, { courseId })}`);
}
return null;
};
RedirectPage.propTypes = {
pattern: PropTypes.string,
mode: PropTypes.string.isRequired,
};
RedirectPage.defaultProps = {
pattern: null,
};
export default RedirectPage;

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import RedirectPage from './RedirectPage';
import { REDIRECT_MODES } from '../constants';
const BASE_URL = getConfig().LMS_BASE_URL;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
courseId: 'course-id-123',
}),
useLocation: () => ({
search: '?consentPath=/some-path',
}),
}));
describe('RedirectPage component', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { assign: jest.fn() },
});
jest.clearAllMocks();
});
it('should handle DASHBOARD_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.DASHBOARD_REDIRECT} pattern="/dashboard" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/dashboard?consentPath=/some-path`);
});
it('should handle CONSENT_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.CONSENT_REDIRECT} />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/some-path`);
});
it('should handle HOME_REDIRECT correctly', () => {
render(
<MemoryRouter>
<RedirectPage mode={REDIRECT_MODES.HOME_REDIRECT} pattern="/course/:courseId/home" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith('/course/course-id-123/home');
});
it('should handle the default case correctly', () => {
render(
<MemoryRouter>
<RedirectPage pattern="/default/:courseId" />
</MemoryRouter>,
);
expect(global.location.assign).toHaveBeenCalledWith(`${BASE_URL}/default/course-id-123`);
});
});

View File

@@ -10,7 +10,6 @@ import { AlertList } from '../../generic/user-messages';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
import Chat from './chat/Chat';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
@@ -92,16 +91,7 @@ const Course = ({
unitId={unitId}
/>
{shouldDisplayTriggers && (
<>
<Chat
enabled={course.learningAssistantEnabled}
enrollmentMode={course.enrollmentMode}
isStaff={isStaff}
courseId={courseId}
contentToolsEnabled={course.showCalculator || course.notes.enabled}
/>
<SidebarTriggers />
</>
<SidebarTriggers />
)}
</div>

View File

@@ -15,10 +15,6 @@ import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
@@ -53,7 +49,8 @@ describe('Course', () => {
setItemSpy.mockRestore();
});
const setupDiscussionSidebar = async () => {
const setupDiscussionSidebar = async (storageValue = false) => {
localStorage.clear();
const testStore = await initializeTestStore({ provider: 'openedx' });
const state = testStore.getState();
const { courseware: { courseId } } = state;
@@ -68,12 +65,14 @@ describe('Course', () => {
mockData.unitId = firstUnitId;
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
if (storageValue !== null) {
localStorage.setItem('showDiscussionSidebar', storageValue);
}
await render(<Course {...mockData} />, { store: testStore });
};
it('loads learning sequence', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
@@ -106,7 +105,7 @@ describe('Course', () => {
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
@@ -124,7 +123,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
@@ -132,71 +131,31 @@ describe('Course', () => {
});
it('displays notification trigger and toggles active class on click', async () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
fireEvent.click(notificationTrigger);
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
expect(discussionsSideBar).not.toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).toHaveClass('d-none');
await act(async () => {
fireEvent.click(discussionsTrigger);
});
await expect(discussionsSideBar).not.toHaveClass('d-none');
});
it('displays discussions sidebar when unit changes', async () => {
const testStore = await initializeTestStore();
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
await setupDiscussionSidebar();
const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
});
rerender(null);
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
});
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true });
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
});
it('handles reload persisting notification tray status', async () => {
sessionStorage.clear();
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
@@ -215,12 +174,13 @@ describe('Course', () => {
it('handles sessionStorage from a different course for the notification tray', async () => {
sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
// set sessionStorage for a different course before rendering Course
sessionStorage.setItem(`notificationTrayStatus.${courseMetadataSecondCourse.id}`, '"open"');
render(<Course {...mockData} />, { wrapWithRouter: true });
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
fireEvent.click(notificationShowButton);
@@ -248,7 +208,7 @@ describe('Course', () => {
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -257,6 +217,34 @@ describe('Course', () => {
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
[
{ value: true, visible: true },
{ value: false, visible: false },
{ value: null, visible: true },
].forEach(async ({ value, visible }) => (
it(`discussion sidebar is ${visible ? 'shown' : 'hidden'} when localstorage value is ${value}`, async () => {
await setupDiscussionSidebar(value);
const element = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
if (visible) {
expect(element).not.toHaveClass('d-none');
} else {
expect(element).toHaveClass('d-none');
}
})));
[
{ value: true, result: 'false' },
{ value: false, result: 'true' },
].forEach(async ({ value, result }) => (
it(`Discussion sidebar storage value is ${!value} when sidebar is ${value ? 'closed' : 'open'}`, async () => {
await setupDiscussionSidebar(value);
await act(async () => {
const button = await screen.queryByRole('button', { name: /Show discussions tray/i });
button.click();
});
expect(localStorage.getItem('showDiscussionSidebar')).toBe(result);
})));
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();
@@ -280,12 +268,12 @@ describe('Course', () => {
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
@@ -309,7 +297,7 @@ describe('Course', () => {
courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
});
@@ -343,7 +331,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
@@ -377,7 +365,7 @@ describe('Course', () => {
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Course {...testData} />, { store: testStore });
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});

View File

@@ -1,80 +1,57 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useSelector } from 'react-redux';
import { useToggle, ModalPopup, Menu } from '@edx/paragon';
import { SelectMenu } from '@edx/paragon';
import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store';
import JumpNavMenuItem from './JumpNavMenuItem';
const CourseBreadcrumb = ({
content,
withSeparator,
courseId,
sequenceId,
unitId,
isStaff,
content, withSeparator, courseId, sequenceId, unitId, isStaff,
}) => {
const defaultContent = content.filter(
(destination) => destination.default,
)[0] || { id: courseId, label: '', sequences: [] };
const showRegularLink = getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
return (
<>
{withSeparator && (
<li className="col-auto p-0 mx-2 text-primary-500 text-truncate text-nowrap" role="presentation" aria-hidden>/</li>
)}
<li
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-testid="breadcrumb-item"
<li style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{showRegularLink ? (
<Link
className="text-primary-500"
to={
defaultContent.sequences.length
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
? (
<Link
className="text-primary-500"
to={defaultContent.sequences.length
? `/course/${courseId}/${defaultContent.sequences[0].id}`
: `/course/${courseId}/${defaultContent.id}`
}
>
{defaultContent.label}
</Link>
) : (
<>
{
// eslint-disable-next-line
<a className="text-primary-500" onClick={open} ref={setTarget}>
{defaultContent.label}
</a>
}
<ModalPopup positionRef={target} isOpen={isOpen} onClose={close}>
<Menu>
{content.map((item) => (
<JumpNavMenuItem
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
onClick={close}
/>
))}
</Menu>
</ModalPopup>
</>
)}
: `/course/${courseId}/${defaultContent.id}`}
>
{defaultContent.label}
</Link>
)
: (
<SelectMenu isLink defaultMessage={defaultContent.label}>
{content.map(item => (
<JumpNavMenuItem
isDefault={item.default}
sequences={item.sequences}
courseId={courseId}
title={item.label}
currentSequence={sequenceId}
currentUnit={unitId}
/>
))}
</SelectMenu>
)}
</li>
</>
);
@@ -110,21 +87,14 @@ const CourseBreadcrumbs = ({
isStaff,
}) => {
const course = useModel('coursewareMeta', courseId);
const courseStatus = useSelector((state) => state.courseware.courseStatus);
const sequenceStatus = useSelector(
(state) => state.courseware.sequenceStatus,
);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const allSequencesInSections = Object.fromEntries(
useModels('sections', course.sectionIds).map((section) => [
section.id,
{
default: section.id === sectionId,
title: section.title,
sequences: useModels('sequences', section.sequenceIds),
},
]),
);
const allSequencesInSections = Object.fromEntries(useModels('sections', course.sectionIds).map(section => [section.id, {
default: section.id === sectionId,
title: section.title,
sequences: useModels('sequences', section.sequenceIds),
}]));
const links = useMemo(() => {
const chapters = [];
@@ -138,7 +108,7 @@ const CourseBreadcrumbs = ({
sequences: section.sequences,
});
if (section.default) {
section.sequences.forEach((sequence) => {
section.sequences.forEach(sequence => {
sequentials.push({
id: sequence.id,
label: sequence.title,
@@ -154,12 +124,11 @@ const CourseBreadcrumbs = ({
return (
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled col-auto m-0 p-0">
<Link
className="flex-shrink-0 text-primary"
to={`/course/${courseId}/home`}
replace
>
<FontAwesomeIcon icon={faHome} className="mr-2" />
<FormattedMessage
@@ -169,7 +138,7 @@ const CourseBreadcrumbs = ({
/>
</Link>
</li>
{links.map((content) => (
{links.map(content => (
<CourseBreadcrumb
courseId={courseId}
sequenceId={sequenceId}

View File

@@ -26,12 +26,6 @@ jest.mock('react-redux', () => ({
Provider: ({ children }) => children,
useSelector: () => 'loaded',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest.fn().mockImplementation(({ to, children }) => (
<a href={to}>{children}</a>
)),
}));
useModels.mockImplementation((name) => {
if (name === 'sections') {
@@ -129,6 +123,6 @@ describe('CourseBreadcrumbs', () => {
const courseHomeButtonDestination = screen.getAllByRole('link')[0].href;
expect(courseHomeButtonDestination).toBe('http://localhost/course/course-v1:edX+DemoX+Demo_Course/home');
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
expect(screen.queryAllByRole('button')).toHaveLength(2);
});
});

View File

@@ -1,12 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { MenuItem } from '@edx/paragon';
import {
sendTrackingLogEvent,
sendTrackEvent,
} from '@edx/frontend-platform/analytics';
import { useNavigate } from 'react-router-dom';
const JumpNavMenuItem = ({
title,
@@ -15,10 +15,7 @@ const JumpNavMenuItem = ({
currentUnit,
sequences,
isDefault,
onClick,
}) => {
const navigate = useNavigate();
function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = {
@@ -37,20 +34,19 @@ const JumpNavMenuItem = ({
}
return `/course/${courseId}/${sequences[0].id}`;
}
function handleClick(e) {
function handleClick() {
const url = destinationUrl();
logEvent(url);
navigate(url);
if (onClick) { onClick(e); }
history.push(url);
}
return (
<Dropdown.Item
active={isDefault}
onClick={e => handleClick(e)}
<MenuItem
defaultSelected={isDefault}
onClick={() => handleClick()}
>
{title}
</Dropdown.Item>
</MenuItem>
);
};
@@ -58,10 +54,6 @@ const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
});
JumpNavMenuItem.defaultProps = {
onClick: null,
};
JumpNavMenuItem.propTypes = {
title: PropTypes.string.isRequired,
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
@@ -69,7 +61,6 @@ JumpNavMenuItem.propTypes = {
courseId: PropTypes.string.isRequired,
currentSequence: PropTypes.string.isRequired,
currentUnit: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
export default JumpNavMenuItem;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { screen, render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import JumpNavMenuItem from './JumpNavMenuItem';
import { fireEvent } from '../../setupTest';
@@ -23,15 +22,12 @@ const mockData = {
},
],
isDefault: false,
onClick: jest.fn().mockName('onClick'),
};
describe('JumpNavMenuItem', () => {
render(
<BrowserRouter>
<JumpNavMenuItem
{...mockData}
/>
</BrowserRouter>,
<JumpNavMenuItem
{...mockData}
/>,
);
it('renders menu Item as expected with button and Text and handles clicks', () => {
expect(screen.queryAllByRole('button')).toHaveLength(1);

View File

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

View File

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

View File

@@ -1,76 +0,0 @@
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import { Xpert } from '@edx/frontend-lib-learning-assistant';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
const Chat = ({
enabled,
enrollmentMode,
isStaff,
courseId,
contentToolsEnabled,
}) => {
const VERIFIED_MODES = [
'professional',
'verified',
'no-id-professional',
'credit',
'masters',
'executive-education',
'paid-executive-education',
'paid-bootcamp',
];
const AUDIT_MODES = [
'audit',
'honor',
'unpaid-executive-education',
'unpaid-bootcamp',
];
const isEnrolled = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& [...VERIFIED_MODES, ...AUDIT_MODES].some(mode => mode === enrollmentMode)
);
const shouldDisplayChat = (
enabled
&& (isEnrolled || isStaff) // display only to enrolled or staff
);
// TODO: Remove this Segment alert. This has been added purely to diagnose whether
// usage issues are as a result of the Xpert toggle button not appearing.
if (shouldDisplayChat) {
sendTrackEvent('edx.ui.lms.learning_assistant.render', {
course_id: courseId,
});
}
return (
<>
{/* Use a portal to ensure that component overlay does not compete with learning MFE styles. */}
{shouldDisplayChat && (createPortal(
<Xpert courseId={courseId} contentToolsEnabled={contentToolsEnabled} />,
document.body,
))}
</>
);
};
Chat.propTypes = {
isStaff: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
enrollmentMode: PropTypes.string,
courseId: PropTypes.string.isRequired,
contentToolsEnabled: PropTypes.bool.isRequired,
};
Chat.defaultProps = {
enrollmentMode: null,
};
export default injectIntl(Chat);

View File

@@ -1,157 +0,0 @@
import { BrowserRouter } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import React from 'react';
import { reducer as learningAssistantReducer } from '@edx/frontend-lib-learning-assistant';
import { initializeMockApp, render, screen } from '../../../setupTest';
import Chat from './Chat';
jest.mock('@edx/frontend-platform/analytics');
initializeMockApp();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let testCases = [];
let enabledTestCases = [];
let disabledTestCases = [];
const enabledModes = [
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
'paid-executive-education', 'paid-bootcamp', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp',
];
const disabledModes = [null, undefined, 'xyz'];
describe('Chat', () => {
// Generate test cases.
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
testCases = enabledTestCases.concat(disabledTestCases);
testCases.forEach(test => {
it(
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={false}
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
// Generate test cases.
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
testCases.forEach(test => {
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff
enabled
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
});
});
// Generate the map function used for generating test cases by currying the map function.
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
// defining two separate map functions that differ in only one case, curry the function.
const generateMapFunction = (areEnabledModes) => (
(mode) => (
[
{
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
},
{
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
},
{
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
},
{
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
},
]
)
);
// Generate test cases.
enabledTestCases = enabledModes.map(generateMapFunction(true));
disabledTestCases = disabledModes.map(generateMapFunction(false));
testCases = enabledTestCases.concat(disabledTestCases);
testCases = testCases.flat();
testCases.forEach(test => {
it(
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
and ${test.enrollmentMode} enrollment mode`,
async () => {
const store = configureStore({
reducer: {
learningAssistant: learningAssistantReducer,
},
});
render(
<BrowserRouter>
<Chat
enrollmentMode={test.enrollmentMode}
isStaff={test.isStaff}
enabled={test.enabled}
courseId={courseId}
contentToolsEnabled={false}
/>
</BrowserRouter>,
{ store },
);
const chat = screen.queryByTestId('toggle-button');
if (test.isVisible) {
expect(chat).toBeInTheDocument();
} else {
expect(chat).not.toBeInTheDocument();
}
},
);
});
});

View File

@@ -1 +0,0 @@
export { default } from './Chat';

View File

@@ -1,32 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import Calculator from './calculator';
import NotesVisibility from './notes-visibility';
const ContentTools = ({
course,
}) => {
const {
sidebarIsOpen,
} = useSelector(state => state.learningAssistant);
return (
!sidebarIsOpen && (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
)
);
};
}) => (
<div className="content-tools">
<div className="d-flex justify-content-end align-items-end m-0">
{course.showCalculator && (
<Calculator />
)}
{course.notes.enabled && (
<NotesVisibility course={course} />
)}
</div>
</div>
);
ContentTools.propTypes = {
course: PropTypes.shape({

View File

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

View File

@@ -25,7 +25,7 @@ class NotesVisibility extends Component {
}
handleClick = () => {
const data = { visibility: !this.state.visible };
const data = { visibility: this.state.visible };
getAuthenticatedHttpClient().put(
this.visibilityUrl,
data,

View File

@@ -85,7 +85,7 @@ describe('Notes Visibility', () => {
expect(axiosMock.history.put).toHaveLength(1);
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${!mockData.course.notes.visible}}`);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
});

View File

@@ -4,7 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { useSelector } from 'react-redux';
import { Navigate } from 'react-router-dom';
import { Redirect } from 'react-router-dom';
import CourseCelebration from './CourseCelebration';
import CourseInProgress from './CourseInProgress';
@@ -58,7 +58,7 @@ const CourseExit = ({ intl }) => {
} else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />);
} else {
return (<Navigate to={`/course/${courseId}`} replace />);
return (<Redirect to={`/course/${courseId}`} />);
}
return (

View File

@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
async function fetchAndRender(component) {
await executeThunk(fetchCourse(courseId), store.dispatch);
render(component, { store, wrapWithRouter: true });
render(component, { store });
}
beforeEach(() => {

View File

@@ -10,6 +10,7 @@ import {
} from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { history } from '@edx/frontend-platform';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -138,6 +139,9 @@ const Sequence = ({
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const goToCourseExitPage = () => {
history.push(`/course/${courseId}/course-end`);
};
const defaultContent = (
<div className="sequence-container d-inline-flex flex-row">
@@ -146,7 +150,7 @@ const Sequence = ({
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
nextHandler={() => {
nextSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
@@ -154,10 +158,11 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousHandler={() => {
previousSequenceHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
@@ -181,6 +186,7 @@ const Sequence = ({
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
goToCourseExitPage={() => goToCourseExitPage()}
/>
)}
</div>

View File

@@ -11,10 +11,6 @@ import Sequence from './Sequence';
import { fetchSequenceFailure } from '../../data/slice';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
checkExamEntry: () => jest.fn(),
}));
describe('Sequence', () => {
let mockData;
@@ -46,14 +42,10 @@ describe('Sequence', () => {
it('renders correctly without data', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
render(
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
{ store: testStore, wrapWithRouter: true },
);
render(<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />, { store: testStore });
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders correctly for gated content', async () => {
@@ -78,14 +70,12 @@ describe('Sequence', () => {
}, false);
const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
// `Previous`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(3);
// `Active` and `Next` buttons.
expect(screen.getAllByRole('link').length).toEqual(2);
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
expect(screen.getAllByRole('button').length).toEqual(5);
expect(screen.getByText('Content Locked')).toBeInTheDocument();
const unitContainer = container.querySelector('.unit-container');
@@ -111,7 +101,7 @@ describe('Sequence', () => {
}, false);
render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
await waitFor(() => {
@@ -122,30 +112,26 @@ describe('Sequence', () => {
// No normal content or navigation should be rendered. Just the above alert.
expect(screen.queryAllByRole('button').length).toEqual(0);
expect(screen.queryAllByRole('link').length).toEqual(1);
});
it('displays error message on sequence load failure', async () => {
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId }));
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...mockData} />, { store: testStore });
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
});
it('handles loading unit', async () => {
render(<Sequence {...mockData} />, { wrapWithRouter: true });
render(<Sequence {...mockData} />);
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// `Previous`, `Bookmark` and `Close Tray` buttons
expect(screen.getAllByRole('button')).toHaveLength(3);
// Renders `Next` button plus one button for each unit.
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
// Renders navigation buttons plus one button for each unit.
expect(screen.getAllByRole('button')).toHaveLength(4 + unitBlocks.length);
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// At this point there will be 2 `Previous` and 2 `Next` buttons.
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
});
describe('sequence and unit navigation buttons', () => {
@@ -174,10 +160,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[1].id,
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
fireEvent.click(sequencePreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
@@ -190,7 +176,7 @@ describe('Sequence', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
.filter(button => button !== sequencePreviousButton)[0];
fireEvent.click(unitPreviousButton);
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
@@ -210,10 +196,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
fireEvent.click(sequenceNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
@@ -225,7 +211,7 @@ describe('Sequence', () => {
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
.filter(button => button !== sequenceNextButton)[0];
fireEvent.click(unitNextButton);
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
@@ -248,14 +234,14 @@ describe('Sequence', () => {
previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
// As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here.
// Therefore the next unit will still be `the initial one + 1`.
@@ -272,7 +258,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -291,7 +277,7 @@ describe('Sequence', () => {
unitNavigationHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
@@ -333,15 +319,15 @@ describe('Sequence', () => {
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: innerTestStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
@@ -381,10 +367,10 @@ describe('Sequence', () => {
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
render(<Sequence {...testData} />, { store: testStore });
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
current_tab: currentTabNumber,
@@ -408,13 +394,13 @@ describe('Sequence', () => {
describe('notification feature', () => {
it('renders notification tray in sequence', async () => {
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
expect(await screen.findByText('Notifications')).toBeInTheDocument();
});
it('handles click on notification tray close button', async () => {
const toggleNotificationTray = jest.fn();
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />);
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
fireEvent.click(notificationCloseIconButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
@@ -422,7 +408,7 @@ describe('Sequence', () => {
it('does not render notification tray in sequence by default if in responsive view', async () => {
global.innerWidth = breakpoints.medium.maxWidth;
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
const { container } = render(<Sequence {...mockData} />);
// unable to test the absence of 'Notifications' by finding it by text, using the class of the tray instead:
expect(container).not.toHaveClass('notification-tray-container');
});

View File

@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
});
it('displays loading message', () => {
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
render(<SequenceContent {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays messages for the locked content', async () => {
const { gatedContent } = store.getState().models.sequences[mockData.sequenceId];
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
const { container } = render(<SequenceContent {...mockData} gated />);
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
expect(await screen.findByText('Content Locked')).toBeInTheDocument();
@@ -38,7 +38,7 @@ describe('Sequence Content', () => {
});
it('displays message for no content', () => {
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
render(<SequenceContent {...mockData} unitId={null} />);
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { Factory } from 'rosie';
import {
initializeTestStore, loadUnit, messageEvent, render, screen, waitFor,
} from '../../../setupTest';
import Unit, { sendUrlHashToFrame } from './Unit';
describe('Unit', () => {
let mockData;
const courseMetadata = Factory.build(
'courseMetadata',
{ content_type_gating_enabled: true },
);
const courseMetadataNeedsSignature = Factory.build(
'courseMetadata',
{ user_needs_integrity_signature: true },
);
const unitBlocks = [
Factory.build(
'block',
{ type: 'vertical', graded: 'true' },
{ courseId: courseMetadata.id },
), Factory.build(
'block',
{
type: 'vertical',
contains_content_type_gated_content: true,
bookmarked: true,
graded: true,
},
{ courseId: courseMetadata.id },
),
Factory.build(
'block',
{ type: 'vertical', graded: false },
{ courseId: courseMetadata.id },
),
];
const [unit, unitThatContainsGatedContent, ungradedUnit] = unitBlocks;
beforeAll(async () => {
await initializeTestStore({ courseMetadata, unitBlocks });
mockData = {
id: unit.id,
courseId: courseMetadata.id,
format: 'Homework',
};
});
it('renders correctly', () => {
render(<Unit {...mockData} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
const renderedUnit = screen.getByTitle(unit.display_name);
expect(renderedUnit).toHaveAttribute('height', String(0));
expect(renderedUnit).toHaveAttribute('src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`);
});
it('renders proper message for gated content', () => {
render(<Unit {...mockData} id={unitThatContainsGatedContent.id} />);
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
});
it('does not display HonorCode for ungraded units', async () => {
const signatureStore = await initializeTestStore(
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
false,
);
const signatureData = {
id: ungradedUnit.id,
courseId: courseMetadataNeedsSignature.id,
format: 'Homework',
};
render(<Unit {...signatureData} />, { store: signatureStore });
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
});
it('displays HonorCode for graded units if user needs integrity signature', async () => {
const signatureStore = await initializeTestStore(
{ courseMetadata: courseMetadataNeedsSignature, unitBlocks },
false,
);
const signatureData = {
id: unit.id,
courseId: courseMetadataNeedsSignature.id,
format: 'Homework',
};
render(<Unit {...signatureData} />, { store: signatureStore });
expect(screen.getByText('Loading honor code messaging...')).toBeInTheDocument();
});
it('handles receiving MessageEvent', async () => {
render(<Unit {...mockData} />);
loadUnit();
// Loading message is gone now.
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
// Iframe's height is set via message.
expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height));
});
it('calls onLoaded after receiving MessageEvent', async () => {
const onLoaded = jest.fn();
render(<Unit {...mockData} {...{ onLoaded }} />);
loadUnit();
await waitFor(() => expect(onLoaded).toHaveBeenCalledTimes(1));
});
it('resizes iframe on second MessageEvent, does not call onLoaded again', async () => {
const onLoaded = jest.fn();
// Clone message and set different height.
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 200 } };
render(<Unit {...mockData} {...{ onLoaded }} />);
loadUnit();
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height)));
window.postMessage(testMessageWithOtherHeight, '*');
await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithOtherHeight.payload.height)));
expect(onLoaded).toHaveBeenCalledTimes(1);
});
it('scrolls page on MessagaeEvent when receiving offset', async () => {
// Set message to constain offset data.
const testMessageWithOffset = { offset: 1500 };
render(<Unit {...mockData} />);
window.postMessage(testMessageWithOffset, '*');
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalled()));
expect(window.scrollY === testMessageWithOffset.offset);
});
it('ignores MessageEvent with unhandled type', async () => {
// Clone message and set different type.
const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' };
render(<Unit {...mockData} />);
window.postMessage(testMessageWithUnhandledType, '*');
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(testMessageWithUnhandledType.payload.height)),
{ timeout: 100 },
)).rejects.toThrowError(/Expected the element to have attribute/);
});
it('scrolls to correct place onLoad', () => {
document.body.innerHTML = "<iframe id='unit-iframe' />";
const mockHashCheck = jest.fn(frameVar => sendUrlHashToFrame(frameVar));
const frame = document.getElementById('unit-iframe');
const originalWindow = { ...window };
const windowSpy = jest.spyOn(global, 'window', 'get');
windowSpy.mockImplementation(() => ({
...originalWindow,
location: {
...originalWindow.location,
hash: '#test',
},
}));
const messageSpy = jest.spyOn(frame.contentWindow, 'postMessage');
messageSpy.mockImplementation(() => ({ hashName: originalWindow.location.hash }));
mockHashCheck(frame);
expect(mockHashCheck).toHaveBeenCalled();
expect(messageSpy).toHaveBeenCalled();
windowSpy.mockRestore();
});
it('calls useEffect and checkForHash', () => {
const mockHashCheck = jest.fn(() => sendUrlHashToFrame());
const effectSpy = jest.spyOn(React, 'useEffect');
effectSpy.mockImplementation(() => mockHashCheck());
render(<Unit {...mockData} />);
expect(React.useEffect).toHaveBeenCalled();
expect(mockHashCheck).toHaveBeenCalled();
});
});

View File

@@ -2,11 +2,11 @@ import PropTypes from 'prop-types';
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { StrictDict } from '@edx/react-unit-test-utils';
import { Modal } from '@edx/paragon';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
import LocalIFrame from './LocalIFrame';
import { renderers } from './constants';
import hooks from './hooks';
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
@@ -18,65 +18,39 @@ import * as hooks from './hooks';
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
const IFRAME_FEATURE_POLICY = (
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
);
export const testIDs = StrictDict({
contentIFrame: 'content-iframe-test-id',
modalIFrame: 'modal-iframe-test-id',
});
const ContentIFrame = ({
iframeUrl,
shouldShowContent,
showContent,
loadingMessage,
id,
elementId,
onLoaded,
title,
childBlocks,
}) => {
const {
handleIFrameLoad,
hasLoaded,
iframeHeight,
showError,
} = hooks.useIFrameBehavior({
elementId,
id,
iframeUrl,
onLoaded,
});
const {
modalOptions,
handleModalClose,
} = hooks.useModalIFrameData();
handleIFrameLoad,
iframeHeight,
} = hooks.useIFrameBehavior({
id,
elementId,
onLoaded,
title,
});
const contentIFrameProps = {
id: elementId,
src: iframeUrl,
allow: IFRAME_FEATURE_POLICY,
allowFullScreen: true,
height: iframeHeight,
scrolling: 'no',
referrerPolicy: 'origin',
onLoad: handleIFrameLoad,
};
return (
<>
{(shouldShowContent && !hasLoaded) && (
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
)}
{shouldShowContent && (
<div className="unit-iframe-wrapper">
<iframe title={title} {...contentIFrameProps} data-testid={testIDs.contentIFrame} />
</div>
)}
{modalOptions.isOpen && (
<Modal
body={modalOptions.body
const renderModal = () => (
<Modal
body={(
<>
{modalOptions.body
? <div className="unit-modal">{ modalOptions.body }</div>
: (
<iframe
@@ -84,14 +58,59 @@ const ContentIFrame = ({
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.url}
style={{ width: '100%', height: modalOptions.height }}
style={{
width: '100%',
height: '100vh',
}}
/>
)}
dialogClassName="modal-lti"
onClose={handleModalClose}
open
/>
</>
)}
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()}
</>
);
};
@@ -99,11 +118,17 @@ const ContentIFrame = ({
ContentIFrame.propTypes = {
iframeUrl: PropTypes.string,
id: PropTypes.string.isRequired,
shouldShowContent: PropTypes.bool.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 = {

View File

@@ -1,175 +0,0 @@
import React from 'react';
import { ErrorPage } from '@edx/frontend-platform/react';
import { Modal } from '@edx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import PageLoading from '../../../../generic/PageLoading';
import * as hooks from './hooks';
import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame';
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: 'ErrorPage' }));
jest.mock('@edx/paragon', () => ({ Modal: 'Modal' }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useIFrameBehavior: jest.fn(),
useModalIFrameData: jest.fn(),
}));
const iframeBehavior = {
handleIFrameLoad: jest.fn().mockName('IFrameBehavior.handleIFrameLoad'),
hasLoaded: false,
iframeHeight: 20,
showError: false,
};
const modalOptions = {
closed: {
isOpen: false,
},
withBody: {
body: 'test-body',
isOpen: true,
},
withUrl: {
isOpen: true,
title: 'test-modal-title',
url: 'test-modal-url',
height: 'test-height',
},
};
const modalIFrameData = {
modalOptions: modalOptions.closed,
handleModalClose: jest.fn().mockName('modalIFrameOptions.handleModalClose'),
};
hooks.useIFrameBehavior.mockReturnValue(iframeBehavior);
hooks.useModalIFrameData.mockReturnValue(modalIFrameData);
const props = {
iframeUrl: 'test-iframe-url',
shouldShowContent: true,
loadingMessage: 'test-loading-message',
id: 'test-id',
elementId: 'test-element-id',
onLoaded: jest.fn().mockName('props.onLoaded'),
title: 'test-title',
};
let el;
describe('ContentIFrame Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('behavior', () => {
beforeEach(() => {
el = shallow(<ContentIFrame {...props} />);
});
it('initializes iframe behavior hook', () => {
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
elementId: props.elementId,
id: props.id,
iframeUrl: props.iframeUrl,
onLoaded: props.onLoaded,
});
});
it('initializes modal iframe data', () => {
expect(hooks.useModalIFrameData).toHaveBeenCalledWith();
});
});
describe('output', () => {
let component;
describe('if shouldShowContent', () => {
describe('if not hasLoaded', () => {
it('displays errorPage if showError', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(ErrorPage).length).toEqual(1);
});
it('displays PageLoading component if not showError', () => {
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(PageLoading);
expect(component.props.srMessage).toEqual(props.loadingMessage);
});
});
describe('hasLoaded', () => {
it('does not display PageLoading or ErrorPage', () => {
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
});
});
it('display iframe with props from hooks', () => {
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByTestId(testIDs.contentIFrame);
expect(component.props).toEqual({
allow: IFRAME_FEATURE_POLICY,
allowFullScreen: true,
scrolling: 'no',
referrerPolicy: 'origin',
title: props.title,
id: props.elementId,
src: props.iframeUrl,
height: iframeBehavior.iframeHeight,
onLoad: iframeBehavior.handleIFrameLoad,
'data-testid': testIDs.contentIFrame,
});
});
});
describe('if not shouldShowContent', () => {
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
el = shallow(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
expect(el.instance.findByType(PageLoading).length).toEqual(0);
expect(el.instance.findByType(ErrorPage).length).toEqual(0);
expect(el.instance.findByTestId(testIDs.contentIFrame).length).toEqual(0);
});
});
it('does not display modal if modalOptions returns isOpen: false', () => {
el = shallow(<ContentIFrame {...props} />);
expect(el.instance.findByType(Modal).length).toEqual(0);
});
describe('if modalOptions.isOpen', () => {
const testModalOpenAndHandleClose = () => {
test('Modal component isOpen, with handleModalClose from hook', () => {
expect(component.props.onClose).toEqual(modalIFrameData.handleModalClose);
});
};
describe('body modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(Modal);
});
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
expect(component.props.body).toEqual(<div className="unit-modal">{modalOptions.withBody.body}</div>);
});
testModalOpenAndHandleClose();
});
describe('url modal', () => {
beforeEach(() => {
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
el = shallow(<ContentIFrame {...props} />);
[component] = el.instance.findByType(Modal);
});
testModalOpenAndHandleClose();
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
expect(component.props.body).toEqual(
<iframe
title={modalOptions.withUrl.title}
allow={IFRAME_FEATURE_POLICY}
frameBorder="0"
src={modalOptions.withUrl.url}
style={{ width: '100%', height: modalOptions.withUrl.height }}
/>,
);
});
});
});
});
});

View File

@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { createPortal } from 'react-dom';
const LocalIFrame = ({
children,
title,
...props
}) => {
const [contentRef, setContentRef] = React.useState(null);
const mountNode = contentRef?.contentWindow?.document?.body;
return (
<iframe title={title} {...props} ref={setContentRef}>
{mountNode && createPortal(children, mountNode)}
</iframe>
);
};
LocalIFrame.propTypes = {
children: PropTypes.node.isRequired,
title: PropTypes.node.isRequired,
};
export default LocalIFrame;

View File

@@ -1,50 +0,0 @@
import React, { Suspense } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import * as hooks from './hooks';
import { modelKeys } from './constants';
const UnitSuspense = ({
courseId,
id,
}) => {
const { formatMessage } = useIntl();
const shouldDisplayHonorCode = hooks.useShouldDisplayHonorCode({ courseId, id });
const unit = useModel(modelKeys.units, id);
const meta = useModel(modelKeys.coursewareMeta, courseId);
const shouldDisplayContentGating = (
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
);
const suspenseComponent = (message, Component) => (
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
<Component courseId={courseId} />
</Suspense>
);
return (
<>
{shouldDisplayContentGating && (
suspenseComponent(messages.loadingLockedContent, LockPaywall)
)}
{shouldDisplayHonorCode && (
suspenseComponent(messages.loadingHonorCode, HonorCode)
)}
</>
);
};
UnitSuspense.propTypes = {
courseId: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
};
export default UnitSuspense;

View File

@@ -1,106 +0,0 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
import LockPaywall from '../lock-paywall';
import hooks from './hooks';
import { modelKeys } from './constants';
import UnitSuspense from './UnitSuspense';
jest.mock('@edx/frontend-platform/i18n', () => ({
defineMessages: m => m,
useIntl: () => ({ formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage }),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
Suspense: 'Suspense',
}));
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({ useModel: jest.fn() }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useShouldDisplayHonorCode: jest.fn(() => false),
}));
const mockModels = (enabled, containsContent) => {
useModel.mockImplementation((key) => (
key === modelKeys.units
? { containsContentTypeGatedContent: containsContent }
: { contentTypeGatingEnabled: enabled }
));
};
const props = {
courseId: 'test-course-id',
id: 'test-id',
};
let el;
describe('UnitSuspense component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockModels(false, false);
});
describe('behavior', () => {
it('initializes models', () => {
el = shallow(<UnitSuspense {...props} />);
const { calls } = useModel.mock;
const [unitCall] = calls.filter(call => call[0] === modelKeys.units);
const [metaCall] = calls.filter(call => call[0] === modelKeys.coursewareMeta);
expect(unitCall[1]).toEqual(props.id);
expect(metaCall[1]).toEqual(props.courseId);
});
});
describe('output', () => {
describe('LockPaywall', () => {
const testNoPaywall = () => {
it('does not display LockPaywal', () => {
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
});
};
describe('gating not enabled', () => { testNoPaywall(); });
describe('gating enabled, but no gated content included', () => {
beforeEach(() => { mockModels(true, false); });
testNoPaywall();
});
describe('gating enabled, gated content included', () => {
beforeEach(() => { mockModels(true, true); });
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(LockPaywall);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
expect(component.props.courseId).toEqual(props.courseId);
});
});
});
describe('HonorCode', () => {
it('does not display HonorCode if useShouldDisplayHonorCode => false', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(false);
el = shallow(<UnitSuspense {...props} />);
expect(el.instance.findByType(HonorCode).length).toEqual(0);
});
it('displays HonorCode component in Suspense wrapper with PageLoading fallback if shouldDisplayHonorCode', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
el = shallow(<UnitSuspense {...props} />);
const [component] = el.instance.findByType(HonorCode);
expect(component.parent.type).toEqual('Suspense');
expect(component.parent.props.fallback)
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />);
expect(component.props.courseId).toEqual(props.courseId);
});
});
});
});

View File

@@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Unit component output BookmarkButton props bookmarked, bookmark update pending snapshot 1`] = `
<BookmarkButton
isBookmarked={true}
isProcessing={false}
unitId="unit-id"
/>
`;
exports[`Unit component output BookmarkButton props not bookmarked, bookmark update loading snapshot 1`] = `
<BookmarkButton
isBookmarked={false}
isProcessing={true}
unitId="unit-id"
/>
`;
exports[`Unit component output snapshot: not bookmarked, do not show content 1`] = `
<div
className="unit"
>
<h1
className="mb-0 h3"
>
unit-title
</h1>
<h2
className="sr-only"
>
Level 2 headings may be created by course providers in the future.
</h2>
<BookmarkButton
isBookmarked={false}
isProcessing={false}
unitId="unit-id"
/>
<UnitSuspense
courseId="test-course-id"
id="test-props-id"
/>
<ContentIFrame
elementId="unit-iframe"
id="test-props-id"
loadingMessage="Loading learning sequence..."
onLoaded={[MockFunction props.onLoaded]}
shouldShowContent={true}
title="unit-title"
/>
</div>
`;

View File

@@ -1,26 +1,12 @@
import { StrictDict } from '@edx/react-unit-test-utils/dist';
import HTMLRenderer from './renderers/HTMLRenderer';
export const modelKeys = StrictDict({
units: 'units',
coursewareMeta: 'coursewareMeta',
});
export const renderers = {
html: HTMLRenderer,
};
export const views = StrictDict({
student: 'student_view',
public: 'public_view',
});
export const FRendlyTypes = Object.keys(renderers);
export const loadingState = 'loading';
export const messageTypes = StrictDict({
modal: 'plugin.modal',
resize: 'plugin.resize',
videoFullScreen: 'plugin.videoFullScreen',
});
export default StrictDict({
modelKeys,
views,
loadingState,
messageTypes,
});
export default {
renderers,
FRendlyTypes,
};

View File

@@ -0,0 +1,211 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppContext } from '@edx/frontend-platform/react';
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { useDispatch } from 'react-redux';
import { processEvent } from '../../../../course-home/data/thunks';
import { useEventListener } from '../../../../generic/hooks';
import { useModel } from '../../../../generic/model-store';
import { fetchCourse } from '../../../data';
import { FRendlyTypes } from './constants';
const useFetchStudentData = ({
id,
}) => {
const [blocks, setBlocks] = useState(null);
const [children, setChildren] = useState(null);
const [isFRendly, setIsFRendly] = useState(false);
const { authenticatedUser } = useContext(AppContext);
useEffect(() => {
if (children) {
setIsFRendly(children.every(child => FRendlyTypes.includes(child.type)));
}
}, [children, setIsFRendly]);
useEffect(() => {
if (blocks) {
setChildren(blocks[id].children.map(childID => blocks[childID]));
}
}, [blocks, setChildren]);
useEffect(() => {
let sequenceUrl;
if (authenticatedUser) {
const { username } = authenticatedUser;
sequenceUrl = `${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/${id}?username=${username}&requested_fields=children&depth=all&student_view_data=video,html`;
getAuthenticatedHttpClient().get(sequenceUrl).then(response => {
console.log({ response });
setBlocks(response.data.blocks);
});
}
}, [authenticatedUser, setBlocks]);
console.log({ isFRendly, children });
return { children, isFRendly };
};
const useUnitData = ({
courseId,
format,
id,
}) => {
const { authenticatedUser } = useContext(AppContext);
const view = authenticatedUser ? 'student_view' : 'public_view';
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
if (format) {
iframeUrl += `&format=${format}`;
}
const { isFRendly, children } = useFetchStudentData({ id });
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
const unit = useModel('units', id);
const course = useModel('coursewareMeta', courseId);
const {
contentTypeGatingEnabled,
userNeedsIntegritySignature,
} = course;
useEffect(() => {
if (userNeedsIntegritySignature && unit.graded) {
setShouldDisplayHonorCode(true);
} else {
setShouldDisplayHonorCode(false);
}
}, [userNeedsIntegritySignature]);
return {
contentTypeGatingEnabled,
iframeUrl,
shouldDisplayHonorCode,
unit,
isFRendly,
children,
};
};
/**
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
* useEffect hooks until the user interacts with the page again. This is particularly confusing
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
* state.
*
* We were able to solve this error by using a layout effect to update some component state, which
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
* a joke) one here so it wouldn't be accidentally removed elsewhere.
*
* If we remove this hook when one of these happens:
* 1. React figures out that there's an issue here and fixes a bug.
* 2. We cease to use an iframe for unit rendering.
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
* 4. We stop supporting Firefox.
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
* Firefox/React for review, and they kindly help us figure out what in the world is happening
* so we can fix it.
*
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
*/
export const useLoadBearingHook = (id) => {
const setValue = useState(0)[1];
useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
};
export const sendUrlHashToFrame = (frame) => {
const { hash } = window.location;
if (hash) {
// The url hash will be sent to LMS-served iframe in order to find the location of the
// hash within the iframe.
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
}
};
const useIFrameBehavior = ({
id,
elementId,
onLoaded,
}) => {
// Do not remove this hook. See function description.
useLoadBearingHook(id);
const dispatch = useDispatch();
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const [showError, setShowError] = useState(false);
const [modalOptions, setModalOptions] = useState({ open: false });
useEffect(() => {
sendUrlHashToFrame(document.getElementById(elementId));
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
const receiveMessage = useCallback(({ data }) => {
const { type, payload } = data;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
} else if (type === 'plugin.modal') {
payload.open = true;
setModalOptions(payload);
} else if (data.offset) {
// We listen for this message from LMS to know when the page needs to
// be scrolled to another location on the page.
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
}
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
useEventListener('message', receiveMessage);
/**
* onLoad *should* only fire after everything in the iframe has finished its own load events.
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
* could have given us a 4xx or 5xx response.
*/
const handleIFrameLoad = () => {
if (!hasLoaded) {
setShowError(true);
}
window.onmessage = (e) => {
if (e.data.event_name) {
dispatch(processEvent(e.data, fetchCourse));
}
};
};
const handleCloseModal = () => {
setModalOptions({ open: false });
};
return {
iframeHeight,
handleCloseModal,
modalOptions,
handleIFrameLoad,
showError,
hasLoaded,
};
};
export default {
useIFrameBehavior,
useUnitData,
};

View File

@@ -1,5 +0,0 @@
export { default as useExamAccess } from './useExamAccess';
export { default as useIFrameBehavior } from './useIFrameBehavior';
export { default as useLoadBearingHook } from './useLoadBearingHook';
export { default as useModalIFrameData } from './useModalIFrameData';
export { default as useShouldDisplayHonorCode } from './useShouldDisplayHonorCode';

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
export const stateKeys = StrictDict({
accessToken: 'accessToken',
blockAccess: 'blockAccess',
});
const useExamAccess = ({
id,
}) => {
const [accessToken, setAccessToken] = useKeyedState(stateKeys.accessToken, '');
const [blockAccess, setBlockAccess] = useKeyedState(stateKeys.blockAccess, isExam());
React.useEffect(() => {
if (isExam()) {
fetchExamAccess()
.finally(() => {
const examAccess = getExamAccess();
setAccessToken(examAccess);
setBlockAccess(false);
})
.catch((error) => {
logError(error);
});
}
}, [id]);
return {
blockAccess,
accessToken,
};
};
export default useExamAccess;

View File

@@ -1,98 +0,0 @@
import React from 'react';
import { logError } from '@edx/frontend-platform/logging';
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { getExamAccess, fetchExamAccess, isExam } from '@edx/frontend-lib-special-exams';
import { isEqual } from 'lodash';
import { waitFor } from '../../../../../setupTest';
import useExamAccess, { stateKeys } from './useExamAccess';
const getEffect = (prereqs) => {
const { calls } = React.useEffect.mock;
const match = calls.filter(call => isEqual(call[1], prereqs));
return match.length ? match[0][0] : null;
};
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('@edx/frontend-lib-special-exams', () => ({
getExamAccess: jest.fn(),
fetchExamAccess: jest.fn(),
isExam: jest.fn(() => false),
}));
const state = mockUseKeyedState(stateKeys);
const id = 'test-id';
const mockFetchExamAccess = Promise.resolve();
fetchExamAccess.mockReturnValue(mockFetchExamAccess);
const testAccessToken = 'test-access-token';
getExamAccess.mockReturnValue(testAccessToken);
describe('useExamAccess hook', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
it('initializes access token to empty string', () => {
useExamAccess({ id });
state.expectInitializedWith(stateKeys.accessToken, '');
});
it('initializes blockAccess to true if is an exam', () => {
useExamAccess({ id });
state.expectInitializedWith(stateKeys.blockAccess, false);
});
it('initializes blockAccess to false if is not an exam', () => {
isExam.mockReturnValueOnce(true);
useExamAccess({ id });
state.expectInitializedWith(stateKeys.blockAccess, true);
});
describe('effects - on id change', () => {
let useEffectCb;
beforeEach(() => {
useExamAccess({ id });
useEffectCb = getEffect([id], React);
});
it('does not call fetchExamAccess if not an exam', () => {
useEffectCb();
expect(fetchExamAccess).not.toHaveBeenCalled();
});
it('fetches and sets exam access if isExam', async () => {
isExam.mockReturnValueOnce(true);
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
state.expectSetStateCalledWith(stateKeys.accessToken, testAccessToken);
state.expectSetStateCalledWith(stateKeys.blockAccess, false);
});
const testError = 'test-error';
it('logs error if fetchExamAccess fails', async () => {
isExam.mockReturnValueOnce(true);
fetchExamAccess.mockReturnValueOnce(Promise.reject(testError));
useEffectCb();
await waitFor(() => expect(fetchExamAccess).toHaveBeenCalled());
expect(logError).toHaveBeenCalledWith(testError);
});
});
});
describe('output', () => {
it('forwards blockAccess and accessToken from state fields', () => {
const testBlockAccess = 'test-block-access';
state.mockVals({
blockAccess: testBlockAccess,
accessToken: testAccessToken,
});
const out = useExamAccess({ id });
expect(out.blockAccess).toEqual(testBlockAccess);
expect(out.accessToken).toEqual(testAccessToken);
state.resetVals();
});
});
});

View File

@@ -1,116 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import React from 'react';
import { useDispatch } from 'react-redux';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useLoadBearingHook from './useLoadBearingHook';
export const stateKeys = StrictDict({
iframeHeight: 'iframeHeight',
hasLoaded: 'hasLoaded',
showError: 'showError',
windowTopOffset: 'windowTopOffset',
});
const useIFrameBehavior = ({
elementId,
id,
iframeUrl,
onLoaded,
}) => {
// Do not remove this hook. See function description.
useLoadBearingHook(id);
const dispatch = useDispatch();
const [iframeHeight, setIframeHeight] = useKeyedState(stateKeys.iframeHeight, 0);
const [hasLoaded, setHasLoaded] = useKeyedState(stateKeys.hasLoaded, false);
const [showError, setShowError] = useKeyedState(stateKeys.showError, false);
const [windowTopOffset, setWindowTopOffset] = useKeyedState(stateKeys.windowTopOffset, null);
React.useEffect(() => {
const frame = document.getElementById(elementId);
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}`);
}
}, [id, onLoaded, iframeHeight, hasLoaded]);
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === messageTypes.resize) {
setIframeHeight(payload.height);
// We observe exit from the video xblock fullscreen mode
// and 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 === messageTypes.videoFullScreen) {
// We listen for this message from LMS to know when we need to
// save or reset scroll position on toggle video xblock fullscreen 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,
onLoaded,
hasLoaded,
setHasLoaded,
iframeHeight,
setIframeHeight,
windowTopOffset,
setWindowTopOffset,
]);
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);
logError('Unit iframe failed to load. Server possibly returned 4xx or 5xx response.', {
iframeUrl,
});
}
window.onmessage = (e) => {
if (e.data.event_name) {
dispatch(processEvent(e.data, fetchCourse));
}
};
};
return {
iframeHeight,
handleIFrameLoad,
showError,
hasLoaded,
};
};
export default useIFrameBehavior;

View File

@@ -1,295 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useIFrameBehavior, { stateKeys } from './useIFrameBehavior';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('./useLoadBearingHook', () => jest.fn());
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('../../../../data', () => ({
fetchCourse: jest.fn(),
}));
jest.mock('../../../../../course-home/data/thunks', () => ({
processEvent: jest.fn((...args) => ({ processEvent: args })),
}));
jest.mock('../../../../../generic/hooks', () => ({
useEventListener: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
const props = {
elementId: 'test-element-id',
id: 'test-id',
iframeUrl: 'test-iframe-url',
onLoaded: jest.fn(),
};
const testIFrameHeight = 42;
const config = { LMS_BASE_URL: 'test-base-url' };
getConfig.mockReturnValue(config);
const dispatch = jest.fn();
useDispatch.mockReturnValue(dispatch);
const postMessage = jest.fn();
const frame = { contentWindow: { postMessage } };
const mockGetElementById = jest.fn(() => frame);
const testHash = '#test-hash';
const defaultStateVals = {
iframeHeight: 0,
hasLoaded: false,
showError: false,
windowTopOffset: null,
};
const stateVals = {
iframeHeight: testIFrameHeight,
hasLoaded: true,
showError: true,
windowTopOffset: 32,
};
describe('useIFrameBehavior hook', () => {
let hook;
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
describe('behavior', () => {
it('initializes iframe height to 0 and error/loaded values to false', () => {
hook = useIFrameBehavior(props);
state.expectInitializedWith(stateKeys.iframeHeight, 0);
state.expectInitializedWith(stateKeys.hasLoaded, false);
state.expectInitializedWith(stateKeys.showError, false);
state.expectInitializedWith(stateKeys.windowTopOffset, null);
});
describe('effects - on frame change', () => {
let oldGetElement;
beforeEach(() => {
global.window ??= Object.create(window);
Object.defineProperty(window, 'location', { value: {}, writable: true });
state.mockVals(stateVals);
oldGetElement = document.getElementById;
document.getElementById = mockGetElementById;
});
afterEach(() => {
state.resetVals();
document.getElementById = oldGetElement;
});
it('does not post url hash if the window does not have one', () => {
hook = useIFrameBehavior(props);
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).not.toHaveBeenCalled();
});
it('posts url hash if the window has one', () => {
window.location.hash = testHash;
hook = useIFrameBehavior(props);
const cb = getEffects([
props.id,
props.onLoaded,
testIFrameHeight,
true,
], React)[0];
cb();
expect(postMessage).toHaveBeenCalledWith({ hashName: testHash }, config.LMS_BASE_URL);
});
});
describe('event listener', () => {
it('calls eventListener with prepared callback', () => {
state.mockVals(stateVals);
hook = useIFrameBehavior(props);
const [call] = useEventListener.mock.calls;
expect(call[0]).toEqual('message');
expect(call[1].prereqs).toEqual([
props.id,
props.onLoaded,
state.values.hasLoaded,
state.setState.hasLoaded,
state.values.iframeHeight,
state.setState.iframeHeight,
state.values.windowTopOffset,
state.setState.windowTopOffset,
]);
});
describe('resize message', () => {
const resizeMessage = (height = 23) => ({
data: { type: messageTypes.resize, payload: { height } },
});
const testSetIFrameHeight = (height = 23) => {
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage(height));
expect(state.setState.iframeHeight).toHaveBeenCalledWith(height);
};
const testOnlySetsHeight = () => {
it('sets iframe height with payload height', () => {
testSetIFrameHeight();
});
it('does not set hasLoaded', () => {
expect(state.setState.hasLoaded).not.toHaveBeenCalled();
});
};
describe('hasLoaded', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
});
describe('iframeHeight is not 0', () => {
beforeEach(() => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
});
testOnlySetsHeight();
});
describe('payload height is 0', () => {
beforeEach(() => { hook = useIFrameBehavior(props); });
testOnlySetsHeight(0);
});
describe('payload is present but uninitialized', () => {
it('sets iframe height with payload height', () => {
hook = useIFrameBehavior(props);
testSetIFrameHeight();
});
it('sets hasLoaded and calls onLoaded', () => {
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
expect(props.onLoaded).toHaveBeenCalled();
});
test('onLoaded is optional', () => {
hook = useIFrameBehavior({ ...props, onLoaded: undefined });
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(state.setState.hasLoaded).toHaveBeenCalledWith(true);
});
});
it('scrolls to current window vertical offset if one is set', () => {
const windowTopOffset = 32;
state.mockVals({ ...defaultStateVals, windowTopOffset });
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(window.scrollTo).toHaveBeenCalledWith(0, windowTopOffset);
});
it('does not scroll if towverticalp offset is not set', () => {
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
cb(resizeMessage());
expect(window.scrollTo).not.toHaveBeenCalled();
});
});
describe('video fullscreen message', () => {
let cb;
const scrollY = 23;
const fullScreenMessage = (open) => ({
data: { type: messageTypes.videoFullScreen, payload: { open } },
});
beforeEach(() => {
window.scrollY = scrollY;
hook = useIFrameBehavior(props);
[[, { cb }]] = useEventListener.mock.calls;
});
it('sets window top offset based on window.scrollY if opening the video', () => {
cb(fullScreenMessage(true));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(scrollY);
});
it('sets window top offset to null if closing the video', () => {
cb(fullScreenMessage(false));
expect(state.setState.windowTopOffset).toHaveBeenCalledWith(null);
});
});
describe('offset message', () => {
it('scrolls to data offset', () => {
const offsetTop = 44;
const mockGetEl = jest.fn(() => ({ offsetTop }));
const oldGetElement = document.getElementById;
document.getElementById = mockGetEl;
const oldScrollTo = window.scrollTo;
window.scrollTo = jest.fn();
hook = useIFrameBehavior(props);
const { cb } = useEventListener.mock.calls[0][1];
const offset = 99;
cb({ data: { offset } });
expect(window.scrollTo).toHaveBeenCalledWith(0, offset + offsetTop);
expect(mockGetEl).toHaveBeenCalledWith('unit-iframe');
document.getElementById = oldGetElement;
window.scrollTo = oldScrollTo;
});
});
});
});
describe('output', () => {
describe('handleIFrameLoad', () => {
it('sets and logs error if has not loaded', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(state.setState.showError).toHaveBeenCalledWith(true);
expect(logError).toHaveBeenCalled();
});
it('does not set/log errors if loaded', () => {
state.mockVals({ ...defaultStateVals, hasLoaded: true });
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
expect(state.setState.showError).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
it('registers an event handler to process fetchCourse events.', () => {
hook = useIFrameBehavior(props);
hook.handleIFrameLoad();
const eventName = 'test-event-name';
const event = { data: { event_name: eventName } };
window.onmessage(event);
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
});
});
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
state.mockVals(stateVals);
hook = useIFrameBehavior(props);
expect(hook.iframeHeight).toEqual(stateVals.iframeHeight);
expect(hook.showError).toEqual(stateVals.showError);
expect(hook.hasLoaded).toEqual(stateVals.hasLoaded);
});
});
});

View File

@@ -1,35 +0,0 @@
import React from 'react';
/**
* 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.
*/
const useLoadBearingHook = (id) => {
const setValue = React.useState(0)[1];
React.useLayoutEffect(() => {
setValue(currentValue => currentValue + 1);
}, [id]);
};
export default useLoadBearingHook;

View File

@@ -1,24 +0,0 @@
import React from 'react';
import useLoadBearingHook from './useLoadBearingHook';
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
useLayoutEffect: jest.fn(),
}));
const setState = jest.fn();
React.useState.mockImplementation((val) => [val, setState]);
const id = 'test-id';
describe('useLoadBearingHook', () => {
it('increments a simple value w/ useLayoutEffect', () => {
useLoadBearingHook(id);
expect(React.useState).toHaveBeenCalledWith(0);
const [[layoutCb, prereqs]] = React.useLayoutEffect.mock.calls;
expect(prereqs).toEqual([id]);
layoutCb();
const [[setValueCb]] = setState.mock.calls;
expect(setValueCb(1)).toEqual(2);
});
});

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '../../../../../generic/hooks';
export const stateKeys = StrictDict({
isOpen: 'isOpen',
options: 'options',
});
export const DEFAULT_HEIGHT = '100vh';
const useModalIFrameBehavior = () => {
const [isOpen, setIsOpen] = useKeyedState(stateKeys.isOpen, false);
const [options, setOptions] = useKeyedState(stateKeys.options, { height: DEFAULT_HEIGHT });
const receiveMessage = React.useCallback(({ data }) => {
const { type, payload } = data;
if (type === 'plugin.modal') {
setOptions((current) => ({ ...current, ...payload }));
setIsOpen(true);
}
}, []);
useEventListener('message', receiveMessage);
const handleModalClose = () => {
setIsOpen(false);
};
return {
handleModalClose,
modalOptions: { isOpen, ...options },
};
};
export default useModalIFrameBehavior;

View File

@@ -1,68 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameBehavior, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('../../../../../generic/hooks', () => ({
useEventListener: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
describe('useModalIFrameBehavior', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
it('initializes isOpen to false', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.isOpen, false);
});
it('initializes options with default height', () => {
useModalIFrameBehavior();
state.expectInitializedWith(stateKeys.options, { height: DEFAULT_HEIGHT });
});
describe('eventListener', () => {
it('consumes modal events and opens sets modal options with open: true', () => {
const oldOptions = { some: 'old', options: 'yeah' };
state.mockVals({
[stateKeys.isOpen]: false,
[stateKeys.options]: oldOptions,
});
useModalIFrameBehavior();
expect(useEventListener).toHaveBeenCalled();
const { cb, prereqs } = useEventListener.mock.calls[0][1];
expect(prereqs).toEqual([]);
const payload = { test: 'values' };
cb({ data: { type: messageTypes.modal, payload } });
expect(state.setState.isOpen).toHaveBeenCalledWith(true);
expect(state.setState.options).toHaveBeenCalled();
const [[setOptionsCb]] = state.setState.options.mock.calls;
expect(setOptionsCb(oldOptions)).toEqual({ ...oldOptions, ...payload });
});
});
});
describe('output', () => {
test('handleModalClose sets modal options to closed', () => {
useModalIFrameBehavior().handleModalClose();
state.expectSetStateCalledWith(stateKeys.isOpen, false);
});
it('forwards modalOptions from state values', () => {
const modalOptions = { test: 'options' };
state.mockVals({
[stateKeys.options]: modalOptions,
[stateKeys.isOpen]: true,
});
expect(useModalIFrameBehavior().modalOptions).toEqual({
...modalOptions,
isOpen: true,
});
});
});
});

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../../generic/model-store';
import { modelKeys } from '../constants';
export const stateKeys = StrictDict({
shouldDisplay: 'shouldDisplay',
});
/**
* @return {bool} should the honor code be displayed?
*/
const useShouldDisplayHonorCode = ({ id, courseId }) => {
const [shouldDisplay, setShouldDisplay] = useKeyedState(stateKeys.shouldDisplay, false);
const { graded } = useModel(modelKeys.units, id);
const { userNeedsIntegritySignature } = useModel(modelKeys.coursewareMeta, courseId);
React.useEffect(() => {
setShouldDisplay(userNeedsIntegritySignature && graded);
}, [setShouldDisplay, userNeedsIntegritySignature]);
return shouldDisplay;
};
export default useShouldDisplayHonorCode;

View File

@@ -1,79 +0,0 @@
import React from 'react';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../../generic/model-store';
import { modelKeys } from '../constants';
import useShouldDisplayHonorCode, { stateKeys } from './useShouldDisplayHonorCode';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('../../../../../generic/model-store', () => ({
useModel: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
const props = {
id: 'test-id',
courseId: 'test-course-id',
};
const mockModels = (graded, userNeedsIntegritySignature) => {
useModel.mockImplementation((key) => (
(key === modelKeys.units) ? { graded } : { userNeedsIntegritySignature }
));
};
describe('useShouldDisplayHonorCode hook', () => {
beforeEach(() => {
jest.clearAllMocks();
mockModels(false, false);
state.mock();
});
describe('behavior', () => {
it('initializes shouldDisplay to false', () => {
useShouldDisplayHonorCode(props);
state.expectInitializedWith(stateKeys.shouldDisplay, false);
});
describe('effect - on userNeedsIntegritySignature', () => {
describe('graded and needs integrity signature', () => {
it('sets shouldDisplay(true)', () => {
mockModels(true, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(true);
});
});
describe('not graded', () => {
it('sets should not display', () => {
mockModels(true, false);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, false], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
describe('does not need integrity signature', () => {
it('sets should not display', () => {
mockModels(false, true);
useShouldDisplayHonorCode(props);
const cb = getEffects([state.setState.shouldDisplay, true], React)[0];
cb();
expect(state.setState.shouldDisplay).toHaveBeenCalledWith(false);
});
});
});
});
describe('output', () => {
it('returns shouldDisplay value from state', () => {
const testValue = 'test-value';
state.mockVal(stateKeys.shouldDisplay, testValue);
expect(useShouldDisplayHonorCode(props)).toEqual(testValue);
});
});
});

View File

@@ -1,18 +1,14 @@
import PropTypes from 'prop-types';
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
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 UnitSuspense from './UnitSuspense';
import { modelKeys, views } from './constants';
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
import { getIFrameUrl } from './urls';
import hooks from './hooks';
const HonorCode = React.lazy(() => import('../honor-code'));
const LockPaywall = React.lazy(() => import('../lock-paywall'));
const Unit = ({
courseId,
@@ -21,18 +17,17 @@ const Unit = ({
id,
}) => {
const { formatMessage } = useIntl();
const { authenticatedUser } = React.useContext(AppContext);
const examAccess = useExamAccess({ id });
const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id });
const unit = useModel(modelKeys.units, id);
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
const iframeUrl = getIFrameUrl({
id,
view,
const {
unit,
contentTypeGatingEnabled,
shouldDisplayHonorCode,
iframeUrl,
isFRendly,
children,
} = hooks.useUnitData({
courseId,
format,
examAccess,
id,
});
return (
@@ -42,16 +37,37 @@ const Unit = ({
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={isProcessing}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
<UnitSuspense {...{ courseId, id }} />
{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
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
showContent={!shouldDisplayHonorCode}
{...(isFRendly ? { childBlocks: children } : { iframeUrl })}
loadingMessage={formatMessage(messages.loadingSequence)}
id={id}
elementId="unit-iframe"
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
</div>

View File

@@ -1,191 +0,0 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
import UnitSuspense from './UnitSuspense';
import ContentIFrame from './ContentIFrame';
import Unit from '.';
import messages from '../messages';
import { getIFrameUrl } from './urls';
import { views } from './constants';
import * as hooks from './hooks';
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
jest.mock('@edx/frontend-platform/i18n', () => {
const utils = jest.requireActual('@edx/react-unit-test-utils/dist');
return {
useIntl: () => ({ formatMessage: utils.formatMessage }),
defineMessages: m => m,
};
});
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('../../bookmark/BookmarkButton', () => 'BookmarkButton');
jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({
useModel: jest.fn(),
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(v => v),
}));
jest.mock('./hooks', () => ({
useExamAccess: jest.fn(),
useShouldDisplayHonorCode: jest.fn(),
}));
jest.mock('./urls', () => ({
getIFrameUrl: jest.fn(),
}));
const props = {
courseId: 'test-course-id',
format: 'test-format',
onLoaded: jest.fn().mockName('props.onLoaded'),
id: 'test-props-id',
};
const context = { authenticatedUser: { test: 'user' } };
React.useContext.mockReturnValue(context);
const examAccess = {
accessToken: 'test-token',
blockAccess: false,
};
hooks.useExamAccess.mockReturnValue(examAccess);
hooks.useShouldDisplayHonorCode.mockReturnValue(false);
const unit = {
id: 'unit-id',
title: 'unit-title',
bookmarked: false,
bookmarkedUpdateState: 'pending',
};
useModel.mockReturnValue(unit);
let el;
describe('Unit component', () => {
beforeEach(() => {
jest.clearAllMocks();
el = shallow(<Unit {...props} />);
});
describe('behavior', () => {
it('initializes hooks', () => {
expect(hooks.useShouldDisplayHonorCode).toHaveBeenCalledWith({
courseId: props.courseId,
id: props.id,
});
});
});
describe('output', () => {
let component;
test('snapshot: not bookmarked, do not show content', () => {
el = shallow(<Unit {...props} />);
expect(el.snapshot).toMatchSnapshot();
});
describe('BookmarkButton props', () => {
const renderComponent = () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(BookmarkButton);
};
describe('not bookmarked, bookmark update loading', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarkedUpdateState: 'loading' });
renderComponent();
});
test('snapshot', () => {
expect(component.snapshot).toMatchSnapshot();
});
test('props', () => {
expect(component.props.isBookmarked).toEqual(false);
expect(component.props.isProcessing).toEqual(true);
expect(component.props.unitId).toEqual(unit.id);
});
});
describe('bookmarked, bookmark update pending', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarked: true });
renderComponent();
});
test('snapshot', () => {
expect(component.snapshot).toMatchSnapshot();
});
test('props', () => {
expect(component.props.isBookmarked).toEqual(true);
expect(component.props.isProcessing).toEqual(false);
expect(component.props.unitId).toEqual(unit.id);
});
});
});
test('UnitSuspense props', () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(UnitSuspense);
expect(component.props.courseId).toEqual(props.courseId);
expect(component.props.id).toEqual(props.id);
});
describe('ContentIFrame props', () => {
const testComponentProps = () => {
expect(component.props.elementId).toEqual('unit-iframe');
expect(component.props.id).toEqual(props.id);
expect(component.props.loadingMessage).toEqual(formatMessage(messages.loadingSequence));
expect(component.props.onLoaded).toEqual(props.onLoaded);
expect(component.props.title).toEqual(unit.title);
};
const loadComponent = () => {
el = shallow(<Unit {...props} />);
[component] = el.instance.findByType(ContentIFrame);
};
describe('shouldShowContent', () => {
test('do not show content if displaying honor code', () => {
hooks.useShouldDisplayHonorCode.mockReturnValueOnce(true);
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(false);
});
test('do not show content if examAccess is blocked', () => {
hooks.useExamAccess.mockReturnValueOnce({ ...examAccess, blockAccess: true });
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(false);
});
test('show content if not displaying honor code or blocked by exam access', () => {
loadComponent();
testComponentProps();
expect(component.props.shouldShowContent).toEqual(true);
});
});
describe('iframeUrl', () => {
test('loads iframe url with student view if authenticated user', () => {
loadComponent();
testComponentProps();
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
id: props.id,
view: views.student,
format: props.format,
examAccess,
}));
});
test('loads iframe url with public view if no authenticated user', () => {
React.useContext.mockReturnValueOnce({});
loadComponent();
testComponentProps();
expect(component.props.iframeUrl).toEqual(getIFrameUrl({
id: props.id,
view: views.public,
format: props.format,
examAccess,
}));
});
});
});
});
});

View File

@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import parse from 'html-react-parser';
const HTMLRenderer = ({ html }) => {
console.log({ html });
return (<div dangerouslySetInnerHTML={{ __html: html }} />);
// return parse(html);
};
HTMLRenderer.propTypes = {
html: PropTypes.string.isRequired,
};
export default HTMLRenderer;

View File

@@ -1,28 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
export const iframeParams = {
show_title: 0,
show_bookmark: 0,
recheck_access: 1,
};
export const getIFrameUrl = ({
id,
view,
format,
examAccess,
}) => {
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
const params = stringify({
...iframeParams,
view,
...(format && { format }),
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
});
return `${xblockUrl}?${params}`;
};
export default {
getIFrameUrl,
};

View File

@@ -1,42 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { stringify } from 'query-string';
import { getIFrameUrl, iframeParams } from './urls';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
jest.mock('query-string', () => ({
stringify: jest.fn((...args) => ({ stringify: args })),
}));
const config = { LMS_BASE_URL: 'test-lms-url' };
getConfig.mockReturnValue(config);
const props = {
id: 'test-id',
view: 'test-view',
format: 'test-format',
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
};
describe('urls module', () => {
describe('getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const params = stringify({
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
});
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('no format provided, exam access blocked', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
id: props.id,
view: props.view,
examAccess: { blockAccess: true },
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
});
});

View File

@@ -1,9 +1,9 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { Button } from '@edx/paragon';
import messages from './messages';
@@ -11,9 +11,8 @@ import messages from './messages';
const ContentLock = ({
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) => {
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(`/course/${courseId}/${prereqId}`);
history.push(`/course/${courseId}/${prereqId}`);
}, [courseId, prereqId]);
return (

View File

@@ -1,16 +1,10 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import {
render, screen, fireEvent, initializeMockApp,
} from '../../../../setupTest';
import ContentLock from './ContentLock';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
describe('Content Lock', () => {
const mockData = {
courseId: 'test-course-id',
@@ -25,7 +19,7 @@ describe('Content Lock', () => {
});
it('displays sequence title along with lock icon', () => {
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
const { container } = render(<ContentLock {...mockData} />);
const lockIcon = container.querySelector('svg');
expect(lockIcon).toHaveClass('fa-lock');
@@ -34,15 +28,16 @@ describe('Content Lock', () => {
it('displays prerequisite name', () => {
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
render(<ContentLock {...mockData} />);
expect(screen.getByText(prereqText)).toBeInTheDocument();
});
it('handles click', () => {
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
history.push = jest.fn();
render(<ContentLock {...mockData} />);
fireEvent.click(screen.getByRole('button'));
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
});
});

View File

@@ -1,18 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ActionRow, Alert, Button } from '@edx/paragon';
import { useNavigate } from 'react-router-dom';
import { useModel } from '../../../../generic/model-store';
import { saveIntegritySignature } from '../../../data';
import messages from './messages';
const HonorCode = ({ intl, courseId }) => {
const navigate = useNavigate();
const dispatch = useDispatch();
const {
isMasquerading,
@@ -22,7 +20,7 @@ const HonorCode = ({ intl, courseId }) => {
const siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
const handleCancel = () => navigate(`/course/${courseId}/home`);
const handleCancel = () => history.push(`/course/${courseId}/home`);
const handleAgree = () => dispatch(
// If the request is made by a staff user masquerading as a specific learner,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
@@ -9,12 +9,12 @@ import {
} from '../../../../setupTest';
import HonorCode from './HonorCode';
const mockNavigate = jest.fn();
initializeMockApp();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
history: {
push: jest.fn(),
},
}));
describe('Honor Code', () => {
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
it('cancel button links to course home ', async () => {
await setupStoreState();
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
});
it('calls to save integrity_signature when agreeing', async () => {
await setupStoreState({ username: authenticatedUser.username });
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
username: authenticatedUser.username,
},
);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
username: 'otheruser',
},
);
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
render(<HonorCode {...mockData} />);
const agreeButton = screen.getByText('I agree');
fireEvent.click(agreeButton);
await waitFor(() => {

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { breakpoints, Button, useWindowSize } from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
@@ -27,13 +26,12 @@ const SequenceNavigation = ({
sequenceId,
className,
onNavigate,
nextHandler,
previousHandler,
nextSequenceHandler,
previousSequenceHandler,
goToCourseExitPage,
}) => {
const sequence = useModel('sequences', sequenceId);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const {
courseId,
sequenceStatus,
@@ -65,49 +63,27 @@ const SequenceNavigation = ({
);
};
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return (
<Button
variant="link"
className="previous-btn"
onClick={previousHandler}
disabled={disabled}
iconBefore={prevArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight;
return (
<Button
variant="link"
className="next-btn"
onClick={nextHandler}
disabled={disabled}
iconAfter={nextArrow}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
</Button>
);
};
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
return sequenceStatus === LOADED && (
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
{renderPreviousButton()}
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
</Button>
{renderUnitButtons()}
{renderNextButton()}
@@ -121,8 +97,9 @@ SequenceNavigation.propTypes = {
unitId: PropTypes.string,
className: PropTypes.string,
onNavigate: PropTypes.func.isRequired,
nextHandler: PropTypes.func.isRequired,
previousHandler: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {

View File

@@ -25,21 +25,22 @@ describe('Sequence Navigation', () => {
mockData = {
unitId: unitBlocks[1].id,
sequenceId: courseware.sequenceId,
previousHandler: () => {},
previousSequenceHandler: () => {},
onNavigate: () => {},
nextHandler: () => {},
nextSequenceHandler: () => {},
goToCourseExitPage: () => {},
};
});
it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
expect(container).toBeEmptyDOMElement();
});
it('renders empty div without unitId', () => {
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
expect(getByText(container, (content, element) => (
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
});
@@ -61,7 +62,7 @@ describe('Sequence Navigation', () => {
sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(),
};
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
render(<SequenceNavigation {...testData} />, { store: testStore });
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
fireEvent.click(unitButton);
@@ -74,27 +75,27 @@ describe('Sequence Navigation', () => {
it('renders correctly and handles unit button clicks', () => {
const onNavigate = jest.fn();
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
expect(unitButtons).toHaveLength(unitButtons.length);
unitButtons.forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);
});
it('has both navigation buttons enabled for a non-corner unit of the sequence', () => {
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} />);
screen.getAllByRole('link', { name: /previous|next/i }).forEach(button => {
screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
expect(button).toBeEnabled();
});
});
it('has the "Previous" button disabled for the first unit of the sequence', () => {
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
@@ -106,10 +107,10 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
@@ -122,11 +123,11 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
});
it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -143,22 +144,22 @@ describe('Sequence Navigation', () => {
render(
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
});
it('handles "Previous" and "Next" click', () => {
const previousHandler = jest.fn();
const nextHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
const previousSequenceHandler = jest.fn();
const nextSequenceHandler = jest.fn();
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
expect(previousHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
expect(nextHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -40,17 +40,14 @@ describe('Sequence Navigation Dropdown', () => {
unitBlocks.forEach((unit, index) => {
it(`marks unit ${index + 1} as active`, async () => {
const { container } = render(
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
{ wrapWithRouter: true },
);
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
const dropdownToggle = container.querySelector('.dropdown-toggle');
await act(async () => {
await fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown-menu');
// Only the current unit should be marked as active.
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => {
if (button.textContent === unit.display_name) {
expect(button).toHaveClass('active');
} else {
@@ -62,17 +59,14 @@ describe('Sequence Navigation Dropdown', () => {
it('handles the clicks', () => {
const onNavigate = jest.fn();
const { container } = render(
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
{ wrapWithRouter: true },
);
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
const dropdownToggle = container.querySelector('.dropdown-toggle');
act(() => {
fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown-menu');
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
unitBlocks.forEach((unit, index) => {
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);

View File

@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
render(<SequenceNavigationTabs {...mockData} />);
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
});
it('renders unit buttons and dropdown button', async () => {
let container = null;
await act(async () => {
useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
const booyah = render(<SequenceNavigationTabs {...mockData} />);
container = booyah.container;
const dropdownToggle = container.querySelector('.dropdown-toggle');
@@ -60,8 +60,8 @@ describe('Sequence Navigation Tabs', () => {
await fireEvent.click(dropdownToggle);
});
const dropdownMenu = container.querySelector('.dropdown');
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
expect(dropdownButtons).toHaveLength(unitBlocks.length);
const dropdownButtons = getAllByRole(dropdownMenu, 'button');
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
.toHaveClass('dropdown-toggle');
});

View File

@@ -1,7 +1,6 @@
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect, useSelector } from 'react-redux';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { Button } from '@edx/paragon';
@@ -21,8 +20,6 @@ const UnitButton = ({
className,
showTitle,
}) => {
const { courseId, sequenceId } = useSelector(state => state.courseware);
const handleClick = useCallback(() => {
onClick(unitId);
}, [onClick, unitId]);
@@ -36,8 +33,6 @@ const UnitButton = ({
variant="link"
onClick={handleClick}
title={title}
as={Link}
to={`/course/${courseId}/${sequenceId}/${unitId}`}
>
<UnitIcon type={contentType} />
{showTitle && <span className="unit-title">{title}</span>}

View File

@@ -32,13 +32,13 @@ describe('Unit Button', () => {
});
it('hides title by default', () => {
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
render(<UnitButton {...mockData} />);
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
});
it('shows title', () => {
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
render(<UnitButton {...mockData} showTitle />);
expect(screen.getByRole('button')).toHaveTextContent(unit.display_name);
});
it('does not show completion for non-completed unit', () => {
@@ -49,7 +49,7 @@ describe('Unit Button', () => {
});
it('shows completion for completed unit', () => {
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />);
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(2);
expect(buttonIcons[1]).toHaveClass('fa-check');
@@ -70,7 +70,7 @@ describe('Unit Button', () => {
});
it('shows bookmark', () => {
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />);
const buttonIcons = container.querySelectorAll('svg');
expect(buttonIcons).toHaveLength(3);
expect(buttonIcons[2]).toHaveClass('fa-bookmark');
@@ -78,8 +78,8 @@ describe('Unit Button', () => {
it('handles the click', () => {
const onClick = jest.fn();
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
fireEvent.click(screen.getByRole('link'));
render(<UnitButton {...mockData} onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -21,32 +20,14 @@ const UnitNavigation = ({
unitId,
onClickPrevious,
onClickNext,
goToCourseExitPage,
}) => {
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
} = useSequenceNavigationMetadata(sequenceId, unitId);
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
const { courseId } = useSelector(state => state.courseware);
const renderPreviousButton = () => {
const disabled = isFirstUnit;
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={disabled}
onClick={onClickPrevious}
as={disabled ? undefined : Link}
to={disabled ? undefined : previousLink}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
);
};
const renderNextButton = () => {
const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl);
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
const disabled = isLastUnit && !exitActive;
const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight;
@@ -54,10 +35,8 @@ const UnitNavigation = ({
<Button
variant="outline-primary"
className="next-button d-flex align-items-center justify-content-center"
onClick={onClickNext}
onClick={buttonOnClick}
disabled={disabled}
as={disabled ? undefined : Link}
to={disabled ? undefined : nextLink}
>
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
{buttonText}
@@ -67,9 +46,18 @@ const UnitNavigation = ({
);
};
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
return (
<div className="unit-navigation d-flex">
{renderPreviousButton()}
<Button
variant="outline-secondary"
className="previous-button mr-2 d-flex align-items-center justify-content-center"
disabled={isFirstUnit}
onClick={onClickPrevious}
>
<FontAwesomeIcon icon={prevArrow} className="mr-2" size="sm" />
{intl.formatMessage(messages.previousButton)}
</Button>
{renderNextButton()}
</div>
);
@@ -81,6 +69,7 @@ UnitNavigation.propTypes = {
unitId: PropTypes.string,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
goToCourseExitPage: PropTypes.func.isRequired,
};
UnitNavigation.defaultProps = {

View File

@@ -22,6 +22,7 @@ describe('Unit Navigation', () => {
sequenceId: courseware.sequenceId,
onClickPrevious: () => {},
onClickNext: () => {},
goToCourseExitPage: () => {},
};
});
@@ -32,10 +33,10 @@ describe('Unit Navigation', () => {
unitId=""
onClickPrevious={() => {}}
onClickNext={() => {}}
/>, { wrapWithRouter: true });
/>);
// Only "Previous" and "Next" buttons should be rendered.
expect(screen.getAllByRole('link')).toHaveLength(2);
expect(screen.getAllByRole('button')).toHaveLength(2);
});
it('handles the clicks', () => {
@@ -44,30 +45,32 @@ describe('Unit Navigation', () => {
render(<UnitNavigation
{...mockData}
sequenceId=""
unitId=""
onClickPrevious={onClickPrevious}
onClickNext={onClickNext}
/>, { wrapWithRouter: true });
/>);
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(onClickPrevious).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('link', { name: /next/i }));
fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(onClickNext).toHaveBeenCalledTimes(1);
});
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
render(<UnitNavigation {...mockData} />);
screen.getAllByRole('link').forEach(button => {
screen.getAllByRole('button').forEach(button => {
expect(button).toBeEnabled();
});
});
it('has the "Previous" button disabled for the first unit in the sequence', () => {
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
});
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
@@ -79,10 +82,10 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
@@ -95,11 +98,11 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
});
it('displays complete course message instead of the "Next" button as needed', async () => {
@@ -116,10 +119,10 @@ describe('Unit Navigation', () => {
render(
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
{ store: testStore, wrapWithRouter: true },
{ store: testStore },
);
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
});
});

View File

@@ -7,7 +7,6 @@ import { sequenceIdsSelector } from '../../../data';
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
const sequenceIds = useSelector(sequenceIdsSelector);
const sequence = useModel('sequences', currentSequenceId);
const courseId = useSelector(state => state.courseware.courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
@@ -15,43 +14,12 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
return { isFirstUnit: false, isLastUnit: false };
}
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
const isFirstSequence = sequenceIndex === 0;
const isFirstUnitInSequence = unitIndex === 0;
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
const isLastUnit = isLastSequence && isLastUnitInSequence;
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
let nextLink;
if (isLastUnit) {
nextLink = `/course/${courseId}/course-end`;
} else {
const nextIndex = unitIndex + 1;
if (nextIndex < sequence.unitIds.length) {
const nextUnitId = sequence.unitIds[nextIndex];
nextLink = `/course/${courseId}/${currentSequenceId}/${nextUnitId}`;
} else if (nextSequenceId) {
nextLink = `/course/${courseId}/${nextSequenceId}/first`;
}
}
let previousLink;
const previousIndex = unitIndex - 1;
if (previousIndex >= 0) {
const previousUnitId = sequence.unitIds[previousIndex];
previousLink = `/course/${courseId}/${currentSequenceId}/${previousUnitId}`;
} else if (previousSequenceId) {
previousLink = `/course/${courseId}/${previousSequenceId}/last`;
}
return {
isFirstUnit, isLastUnit, nextLink, previousLink,
};
return { isFirstUnit, isLastUnit };
}

View File

@@ -5,6 +5,8 @@ import React, {
} from 'react';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { getSessionStorage } from '../../../data/sessionStorage';
import { useModel } from '../../../generic/model-store';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
@@ -13,18 +15,32 @@ const SidebarProvider = ({
unitId,
children,
}) => {
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
const query = new URLSearchParams(window.location.search);
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
if (query.get('sidebar') === 'true') {
localStorage.setItem('showDiscussionSidebar', true);
}
const showDiscussionSidebar = localStorage.getItem('showDiscussionSidebar') !== 'false';
const showNotificationSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
? SIDEBARS.NOTIFICATIONS.ID
: null;
const initialSidebar = showDiscussionSidebar
? SIDEBARS.DISCUSSIONS.ID
: showNotificationSidebar;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
useEffect(() => {
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID);
// As a one-off set initial sidebar if the verified mode data has just loaded
if (verifiedMode && currentSidebar === null && initialSidebar) {
setCurrentSidebar(initialSidebar);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unitId]);
}, [initialSidebar, verifiedMode]);
const onNotificationSeen = useCallback(() => {
setNotificationStatus('inactive');
@@ -33,6 +49,11 @@ const SidebarProvider = ({
const toggleSidebar = useCallback((sidebarId) => {
// Switch to new sidebar or hide the current sidebar
if (currentSidebar === SIDEBARS.DISCUSSIONS.ID) {
localStorage.setItem('showDiscussionSidebar', false);
} else if (sidebarId === SIDEBARS.DISCUSSIONS.ID) {
localStorage.setItem('showDiscussionSidebar', true);
}
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
}, [currentSidebar]);

View File

@@ -3,7 +3,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { QuestionAnswer } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useMemo } from 'react';
import React, { useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useModel } from '../../../../../generic/model-store';
import { getCourseDiscussionTopics } from '../../../../data/thunks';
@@ -23,16 +23,12 @@ const DiscussionsTrigger = ({
courseId,
} = useContext(SidebarContext);
const dispatch = useDispatch();
const { tabs } = useModel('courseHomeMeta', courseId);
const topic = useModel('discussionTopics', unitId);
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
const edxProvider = useMemo(
() => tabs?.find(tab => tab.slug === 'discussion'),
[tabs],
);
useEffect(() => {
if (baseUrl && edxProvider) {
// Only fetch the topic data if the MFE is configured.
if (baseUrl) {
dispatch(getCourseDiscussionTopics(courseId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -57,5 +57,4 @@ Factory.define('courseMetadata')
related_programs: null,
user_needs_integrity_signature: false,
recommendations: null,
learning_assistant_enabled: null,
});

View File

@@ -122,7 +122,6 @@ function normalizeMetadata(metadata) {
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
canAccessProctoredExams: data.can_access_proctored_exams,
learningAssistantEnabled: data.learning_assistant_enabled,
};
}

View File

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

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
const withParamsAndNavigation = WrappedComponent => {
const WithParamsNavigationComponent = props => {
const { courseId, sequenceId, unitId } = useParams();
const navigate = useNavigate();
return (
<WrappedComponent
routeCourseId={courseId}
routeSequenceId={sequenceId}
routeUnitId={unitId}
navigate={navigate}
{...props}
/>
);
};
return WithParamsNavigationComponent;
};
export default withParamsAndNavigation;

View File

@@ -2,16 +2,15 @@
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
<div>
PageWrap: {
"children": [
" ",
[
" ",
[],
" "
],
" "
]
PageRoute: {
"computedMatch": {
"path": "/course/:courseId/home",
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
"isExact": true,
"params": {
"courseId": "course-v1:edX+DemoX+Demo_Course"
}
}
}
</div>
`;

View File

@@ -1,15 +1,7 @@
import PropTypes from 'prop-types';
import { PageWrap } from '@edx/frontend-platform/react';
import { PageRoute } from '@edx/frontend-platform/react';
import React from 'react';
import {
generatePath, useMatch, Navigate,
} from 'react-router-dom';
import { DECODE_ROUTES } from '../constants';
const ROUTES = [].concat(
...Object.values(DECODE_ROUTES).map(value => (Array.isArray(value) ? value : [value])),
);
import { useHistory, generatePath } from 'react-router';
export const decodeUrl = (encodedUrl) => {
const decodedUrl = decodeURIComponent(encodedUrl);
@@ -19,16 +11,10 @@ export const decodeUrl = (encodedUrl) => {
return decodeUrl(decodedUrl);
};
const DecodePageRoute = ({ children }) => {
let computedMatch = null;
ROUTES.forEach((route) => {
const matchedRoute = useMatch(route);
if (matchedRoute) { computedMatch = matchedRoute; }
});
if (computedMatch) {
const { pathname, pattern, params } = computedMatch;
const DecodePageRoute = (props) => {
const history = useHistory();
if (props.computedMatch) {
const { url, path, params } = props.computedMatch;
Object.keys(params).forEach((param) => {
// only decode params not the entire url.
@@ -36,19 +22,28 @@ const DecodePageRoute = ({ children }) => {
params[param] = decodeUrl(params[param]);
});
const newUrl = generatePath(pattern.path, params);
const newUrl = generatePath(path, params);
// if the url get decoded, reroute to the decoded url
if (newUrl !== pathname) {
return <Navigate to={newUrl} replace />;
if (newUrl !== url) {
history.replace(newUrl);
}
}
return <PageWrap> {children} </PageWrap>;
return <PageRoute {...props} />;
};
DecodePageRoute.propTypes = {
children: PropTypes.node.isRequired,
computedMatch: PropTypes.shape({
url: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
params: PropTypes.any,
}),
};
DecodePageRoute.defaultProps = {
computedMatch: null,
};
export default DecodePageRoute;

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import {
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
} from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { Router, matchPath } from 'react-router';
import DecodePageRoute, { decodeUrl } from '.';
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -16,90 +15,84 @@ const deepEncodedCourseId = (() => {
})();
jest.mock('@edx/frontend-platform/react', () => ({
PageWrap: (props) => `PageWrap: ${JSON.stringify(props, null, 2)}`,
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
}));
jest.mock('../constants', () => ({
DECODE_ROUTES: {
MOCK_ROUTE_1: '/course/:courseId/home',
MOCK_ROUTE_2: `/course/:courseId/${encodeURIComponent('some+thing')}/:unitId`,
},
}));
jest.mock('react-router-dom', () => {
const mockNavigation = jest.fn();
// eslint-disable-next-line react/prop-types
const Navigate = ({ to }) => {
mockNavigation(to);
return <div />;
};
return {
...jest.requireActual('react-router-dom'),
Navigate,
mockNavigate: mockNavigation,
};
});
const renderPage = (props) => {
const memHistory = createMemoryHistory({
initialEntries: [props?.path],
});
const history = {
...memHistory,
replace: jest.fn(),
};
const { container } = render(
<Router initialEntries={[props?.pathname]}>
<Routes>
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
</Routes>
<Router history={history}>
<DecodePageRoute computedMatch={props} />
</Router>,
);
return { container };
return {
container,
history,
props,
};
};
describe('DecodePageRoute', () => {
afterEach(() => {
mockNavigate.mockClear();
});
it('should not modify the url if it does not need to be decoded', () => {
const props = matchPath({
const props = matchPath(`/course/${decodedCourseId}/home`, {
path: '/course/:courseId/home',
}, `/course/${decodedCourseId}/home`);
const { container } = renderPage(props);
});
const { container, history } = renderPage(props);
expect(props.pathname).toContain(decodedCourseId);
expect(mockNavigate).not.toHaveBeenCalled();
expect(props.url).toContain(decodedCourseId);
expect(history.replace).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('should decode the url and replace the history if necessary', () => {
const props = matchPath({
const props = matchPath(`/course/${encodedCourseId}/home`, {
path: '/course/:courseId/home',
}, `/course/${encodedCourseId}/home`);
renderPage(props);
});
const { history } = renderPage(props);
expect(props.pathname).not.toContain(decodedCourseId);
expect(props.pathname).toContain(encodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(encodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
});
it('should decode the url multiple times if necessary', () => {
const props = matchPath({
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
path: '/course/:courseId/home',
}, `/course/${deepEncodedCourseId}/home`);
renderPage(props);
});
const { history } = renderPage(props);
expect(props.pathname).not.toContain(decodedCourseId);
expect(props.pathname).toContain(deepEncodedCourseId);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(deepEncodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
});
it('should only decode the url params and not the entire url', () => {
const decodedUnitId = 'some+thing';
const encodedUnitId = encodeURIComponent(decodedUnitId);
const props = matchPath({
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
path: `/course/:courseId/${encodedUnitId}/:unitId`,
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
renderPage(props);
});
const { history } = renderPage(props);
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
const decodedUrls = history.replace.mock.calls[0][0].split('/');
// unitId get decoded
expect(decodedUrls.pop()).toContain(decodedUnitId);
// path remain encoded
expect(decodedUrls.pop()).toContain(encodedUnitId);
// courseId get decoded
expect(decodedUrls.pop()).toContain(decodedCourseId);
});
});

View File

@@ -1,8 +1,9 @@
import React, { useEffect } from 'react';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import Footer from '@edx/frontend-component-footer';
import { useParams, Navigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { Redirect } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import useActiveEnterpriseAlert from '../alerts/active-enteprise-alert';
import { AlertList } from './user-messages';
@@ -37,7 +38,7 @@ const CourseAccessErrorPage = ({ intl }) => {
);
}
if (courseStatus === LOADED) {
return <Navigate to={`/redirect/home/${courseId}`} replace />;
return (<Redirect to={`/redirect/home/${courseId}`} />);
}
return (
<>

View File

@@ -1,13 +1,11 @@
import React from 'react';
import { history } from '@edx/frontend-platform';
import { Routes, Route } from 'react-router-dom';
import { Route } from 'react-router';
import { initializeTestStore, render, screen } from '../setupTest';
import CourseAccessErrorPage from './CourseAccessErrorPage';
const mockDispatch = jest.fn();
const mockNavigate = jest.fn();
let mockCourseStatus;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
@@ -16,10 +14,6 @@ jest.mock('react-redux', () => ({
jest.mock('./PageLoading', () => function () {
return <div data-testid="page-loading" />;
});
jest.mock('react-router-dom', () => ({
...(jest.requireActual('react-router-dom')),
useNavigate: () => mockNavigate,
}));
describe('CourseAccessErrorPage', () => {
let courseId;
@@ -34,36 +28,33 @@ describe('CourseAccessErrorPage', () => {
it('Displays loading in start on page rendering', () => {
mockCourseStatus = 'loading';
render(
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
expect(window.location.pathname).toBe(accessDeniedUrl);
expect(history.location.pathname).toBe(accessDeniedUrl);
});
it('Redirect user to homepage if user has access', () => {
mockCourseStatus = 'loaded';
render(
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
});
it('For access denied it should render access denied page', () => {
mockCourseStatus = 'denied';
render(
<Routes>
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
</Routes>,
{ wrapWithRouter: true },
<Route path="/course/:courseId/access-denied">
<CourseAccessErrorPage />
</Route>,
);
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
expect(window.location.pathname).toBe(accessDeniedUrl);
expect(history.location.pathname).toBe(accessDeniedUrl);
});
});

View File

@@ -1,4 +1,4 @@
import { Navigate, useLocation } from 'react-router-dom';
import { Redirect, useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -16,10 +16,10 @@ const PathFixesProvider = ({ children }) => {
// We only check for spaces. That's not the only kind of character that is escaped in URLs, but it would always be
// present for our cases, and I believe it's the only one we use normally.
if (location.pathname.includes(' ') || location.pathname.includes('%20')) {
if (location.pathname.includes(' ')) {
const newLocation = {
...location,
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
pathname: location.pathname.replaceAll(' ', '+'),
};
sendTrackEvent('edx.ui.lms.path_fixed', {
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
search: location.search,
});
return (<Navigate to={newLocation} replace />);
return (<Redirect to={newLocation} />);
}
return children; // pass through

Some files were not shown because too many files have changed in this diff Show More