Compare commits
71 Commits
open-relea
...
oliv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24568f0bd | ||
|
|
5604def491 | ||
|
|
b788b969c3 | ||
|
|
b7a3d5640a | ||
|
|
3a21d8c807 | ||
|
|
81442bebe9 | ||
|
|
168ed1e184 | ||
|
|
c8e32c3f46 | ||
|
|
51dd90741b | ||
|
|
f58d6d6d25 | ||
|
|
81a49bd755 | ||
|
|
2ae033160f | ||
|
|
32bd3190a6 | ||
|
|
645ac2cb5f | ||
|
|
ee80b24cba | ||
|
|
ee1d816cc8 | ||
|
|
e8ac2ffc7e | ||
|
|
62d3e95cc8 | ||
|
|
ce6771d7cc | ||
|
|
1dcde821b4 | ||
|
|
694e3ed6d5 | ||
|
|
ba843622c2 | ||
|
|
2d29827e6b | ||
|
|
2b9b3db5d3 | ||
|
|
2e90e214b4 | ||
|
|
ea2d7ed839 | ||
|
|
5ee61904d5 | ||
|
|
6232b0cb98 | ||
|
|
09542338a2 | ||
|
|
c3d345e642 | ||
|
|
ec2bf60345 | ||
|
|
b0c71e5291 | ||
|
|
dcd6847254 | ||
|
|
d2df9241c3 | ||
|
|
1871e491a7 | ||
|
|
03543c0af1 | ||
|
|
0c49658314 | ||
|
|
2a1173584e | ||
|
|
398330fa07 | ||
|
|
f92fc8c3a5 | ||
|
|
5e072949d6 | ||
|
|
2d132f114c | ||
|
|
c73ef26d8e | ||
|
|
97ca7fe6aa | ||
|
|
e95a59c6c8 | ||
|
|
5f9c441cd2 | ||
|
|
2e641ac6c9 | ||
|
|
22937918ab | ||
|
|
714f5d452c | ||
|
|
8ac9745261 | ||
|
|
340580cb41 | ||
|
|
5a99ca5c91 | ||
|
|
9943df49e4 | ||
|
|
855474d406 | ||
|
|
a78496a3f6 | ||
|
|
79b65dadca | ||
|
|
fc8f5d43e8 | ||
|
|
6232f40a74 | ||
|
|
bc0ff1ce65 | ||
|
|
5997b29cee | ||
|
|
d2de0632cd | ||
|
|
922cc2187a | ||
|
|
d9539796b5 | ||
|
|
e0acb501eb | ||
|
|
a03ffe2724 | ||
|
|
cbdf7ce064 | ||
|
|
7184e85b2b | ||
|
|
b5321d01e4 | ||
|
|
6c8ab1a4c9 | ||
|
|
01f9d8f50b | ||
|
|
764befd4bd |
1
.env
1
.env
@@ -46,3 +46,4 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
|
||||
@@ -15,7 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
@@ -46,3 +46,6 @@ 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=''
|
||||
|
||||
@@ -45,3 +45,4 @@ 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'
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
18
Makefile
18
Makefile
@@ -1,6 +1,7 @@
|
||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
|
||||
|
||||
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
|
||||
@@ -42,9 +43,24 @@ 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:
|
||||
|
||||
@@ -9,6 +9,12 @@ 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'
|
||||
});
|
||||
|
||||
14093
package-lock.json
generated
14093
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -30,43 +30,45 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/frontend-component-footer": "^12.0.0",
|
||||
"@edx/frontend-component-header": "^4.0.0",
|
||||
"@edx/frontend-lib-special-exams": "^2.16.1",
|
||||
"@edx/frontend-platform": "^4.2.0",
|
||||
"@edx/paragon": "20.28.4",
|
||||
"@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",
|
||||
"@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.18",
|
||||
"@popperjs/core": "2.11.6",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"js-cookie": "3.0.1",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "7.1.3",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.7",
|
||||
"reselect": "4.1.8",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.1.1",
|
||||
"@edx/frontend-build": "^12.8.27",
|
||||
"@edx/reactifex": "2.1.1",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/frontend-build": "^12.9.10",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
|
||||
@@ -68,7 +68,7 @@ const CourseStartAlert = ({ payload }) => {
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.long"
|
||||
id="learning.outline.alert.start.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.end.calendar"
|
||||
id="learning.outline.alert.start.calendar"
|
||||
defaultMessage="Don’t 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. "
|
||||
/>
|
||||
|
||||
33
src/constants.js
Normal file
33
src/constants.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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',
|
||||
};
|
||||
@@ -28,6 +28,7 @@ Factory.define('outlineTabData')
|
||||
upgrade_url: `${host}/dashboard`,
|
||||
}))
|
||||
.attrs({
|
||||
course_access_redirect: false,
|
||||
has_scheduled_content: null,
|
||||
access_expiration: null,
|
||||
can_show_upgrade_sock: false,
|
||||
|
||||
@@ -18,6 +18,9 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
@@ -336,6 +339,9 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
@@ -532,6 +538,9 @@ Object {
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
},
|
||||
"learningAssistant": ObjectContaining {
|
||||
"conversationId": Any<String>,
|
||||
},
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
|
||||
@@ -204,12 +204,18 @@ export async function getDatesTabData(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -259,7 +265,7 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
|
||||
return camelCasedData;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
const httpErrorStatus = error?.response?.status;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
|
||||
return {};
|
||||
@@ -269,6 +275,12 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -322,7 +334,20 @@ 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();
|
||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
||||
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 responseTime = Date.now();
|
||||
|
||||
const {
|
||||
|
||||
@@ -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,
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
|
||||
@@ -28,15 +28,11 @@ const provider = new Pact({
|
||||
describe('Course Home Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, '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(() => {
|
||||
|
||||
@@ -21,6 +21,18 @@ 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(() => {
|
||||
@@ -55,16 +67,40 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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(`${outlineBaseUrl}/${courseId}`).networkError();
|
||||
axiosMock.onGet(outlineUrl).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
|
||||
@@ -75,8 +111,6 @@ 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);
|
||||
|
||||
@@ -84,8 +118,31 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -113,7 +170,14 @@ describe('Data layer integration tests', () => {
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.courseStatus).toEqual('loaded');
|
||||
expect(state).toMatchSnapshot();
|
||||
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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('Should handle the url including a targetUserId', async () => {
|
||||
@@ -129,6 +193,19 @@ 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', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
@@ -32,11 +32,16 @@ describe('DatesTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/dates"
|
||||
element={(
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -38,21 +38,21 @@ function getBadgeListAndColor(date, intl, item, items) {
|
||||
message: messages.today,
|
||||
shownForDay: isToday,
|
||||
bg: 'bg-warning-300',
|
||||
className: 'text-dark',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.completed,
|
||||
shownForDay: assignments.length && assignments.every(isComplete),
|
||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||
bg: 'bg-light-500',
|
||||
className: 'text-dark',
|
||||
className: 'text-black',
|
||||
},
|
||||
{
|
||||
message: messages.pastDue,
|
||||
shownForDay: assignments.length && assignments.every(isPastDue),
|
||||
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
|
||||
bg: 'bg-dark-200',
|
||||
className: 'text-dark',
|
||||
className: 'text-white',
|
||||
},
|
||||
{
|
||||
message: messages.dueNext,
|
||||
|
||||
@@ -2,21 +2,20 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, generatePath, useNavigate } 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 history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, iFrameHeight] = useIFrameHeight();
|
||||
useIFramePluginEvents({
|
||||
'discussions.navigate': (payload) => {
|
||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||
history.push(`${basePath}/${payload.path}`);
|
||||
navigate(`${basePath}/${payload.path}`);
|
||||
},
|
||||
});
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
||||
|
||||
@@ -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 } from 'react-router';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
import {
|
||||
@@ -30,11 +30,16 @@ describe('DiscussionTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/discussion">
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/course/:courseId/discussion"
|
||||
element={(
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import {
|
||||
MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getConfig } 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';
|
||||
@@ -24,13 +26,16 @@ describe('GoalUnsubscribe', () => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
|
||||
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
|
||||
<Routes>
|
||||
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
|
||||
});
|
||||
|
||||
it('starts with a spinner', () => {
|
||||
|
||||
@@ -9,9 +9,8 @@ const LmsHtmlFragment = ({
|
||||
title,
|
||||
...rest
|
||||
}) => {
|
||||
const direction = document.documentElement?.getAttribute('dir') || 'ltr';
|
||||
const wholePage = `
|
||||
<html dir="${direction}">
|
||||
<html>
|
||||
<head>
|
||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } 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';
|
||||
@@ -67,6 +66,7 @@ const OutlineTab = ({ intl }) => {
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
@@ -115,8 +115,10 @@ 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');
|
||||
history.replace({
|
||||
search: currentParams.toString(),
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${currentParams.toString()}`,
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -17,45 +16,46 @@ 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) => {
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => {
|
||||
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) {
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true });
|
||||
} else if (firstSequenceId) {
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
||||
history.replace(`/course/${courseId}/${unitId}`);
|
||||
navigate(`/course/${courseId}/${unitId}`, { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
|
||||
// If the section is non-empty, redirect to its first sequence.
|
||||
if (section.sequenceIds && section.sequenceIds[0]) {
|
||||
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
|
||||
navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true });
|
||||
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
||||
} else {
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkUnitToSequenceUnitRedirect = memoize(
|
||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
|
||||
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => {
|
||||
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,60 +64,62 @@ const checkUnitToSequenceUnitRedirect = memoize(
|
||||
getSequenceForUnitDeprecated(courseId, unitId).then(
|
||||
parentId => {
|
||||
if (parentId) {
|
||||
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
|
||||
navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true });
|
||||
} else {
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
}
|
||||
},
|
||||
() => { // error case
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Invalid sequence that isn't a unit either. Redirect up to main course.
|
||||
history.replace(`/course/${courseId}`);
|
||||
navigate(`/course/${courseId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
||||
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||
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.
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Look at where this is called in componentDidUpdate for more info about its usage
|
||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
|
||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUnits = sequence.unitIds?.length > 0;
|
||||
|
||||
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
|
||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
||||
const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize(
|
||||
(courseId, sequenceStatus, sequence, unitId, navigate) => {
|
||||
if (sequenceStatus !== 'loaded' || !sequence.id) {
|
||||
return;
|
||||
}
|
||||
} else if (unitId === 'last') {
|
||||
if (hasUnits) {
|
||||
const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1];
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${lastUnitId}`);
|
||||
} else {
|
||||
|
||||
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 {
|
||||
// No units... go to general sequence page
|
||||
history.replace(`/course/${courseId}/${sequence.id}`);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
class CoursewareContainer extends Component {
|
||||
checkSaveSequencePosition = memoize((unitId) => {
|
||||
@@ -145,12 +147,8 @@ class CoursewareContainer extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
},
|
||||
},
|
||||
routeCourseId,
|
||||
routeSequenceId,
|
||||
} = this.props;
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
@@ -167,13 +165,10 @@ class CoursewareContainer extends Component {
|
||||
sequence,
|
||||
firstSequenceId,
|
||||
sectionViaSequenceId,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
routeCourseId,
|
||||
routeSequenceId,
|
||||
routeUnitId,
|
||||
navigate,
|
||||
} = this.props;
|
||||
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
@@ -202,7 +197,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);
|
||||
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate);
|
||||
|
||||
// Check section-unit to unit redirect:
|
||||
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
|
||||
@@ -215,60 +210,54 @@ 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);
|
||||
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
|
||||
|
||||
// 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);
|
||||
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate);
|
||||
|
||||
// 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
|
||||
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit,
|
||||
sequenceId, sectionViaSequenceId, routeUnitId, navigate
|
||||
));
|
||||
|
||||
// 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);
|
||||
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
|
||||
|
||||
// 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);
|
||||
checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate);
|
||||
}
|
||||
|
||||
handleUnitNavigationClick = (nextUnitId) => {
|
||||
handleUnitNavigationClick = () => {
|
||||
const {
|
||||
courseId, sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
courseId,
|
||||
sequenceId,
|
||||
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);
|
||||
@@ -276,23 +265,14 @@ class CoursewareContainer extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
handlePreviousSequenceClick = () => {
|
||||
const { previousSequence, courseId } = this.props;
|
||||
if (previousSequence !== null) {
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/last`);
|
||||
}
|
||||
};
|
||||
handlePreviousSequenceClick = () => {};
|
||||
|
||||
render() {
|
||||
const {
|
||||
courseStatus,
|
||||
courseId,
|
||||
sequenceId,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
routeUnitId,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@@ -335,13 +315,9 @@ const courseShape = PropTypes.shape({
|
||||
});
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
routeCourseId: PropTypes.string.isRequired,
|
||||
routeSequenceId: PropTypes.string,
|
||||
routeUnitId: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
firstSequenceId: PropTypes.string,
|
||||
@@ -357,11 +333,14 @@ 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,
|
||||
@@ -476,4 +455,4 @@ export default connect(mapStateToProps, {
|
||||
saveSequencePosition,
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
})(CoursewareContainer);
|
||||
})(withParamsAndNavigation(CoursewareContainer));
|
||||
|
||||
@@ -5,13 +5,16 @@ 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 { Route, Switch } from 'react-router';
|
||||
import {
|
||||
BrowserRouter, MemoryRouter, Route, Routes,
|
||||
} from 'react-router-dom';
|
||||
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 CoursewareContainer from './CoursewareContainer';
|
||||
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
|
||||
@@ -80,18 +83,16 @@ describe('CoursewareContainer', () => {
|
||||
store = initializeStore();
|
||||
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
</Switch>
|
||||
<Routes>
|
||||
{DECODE_ROUTES.COURSEWARE.map((route) => (
|
||||
<Route
|
||||
path={route}
|
||||
element={<CoursewareContainer />}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -151,7 +152,7 @@ describe('CoursewareContainer', () => {
|
||||
}
|
||||
|
||||
async function loadContainer() {
|
||||
const { container } = render(component);
|
||||
const { container } = render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
// 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'));
|
||||
@@ -160,7 +161,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should initialize to show a spinner', () => {
|
||||
history.push('/course/abc123');
|
||||
render(component);
|
||||
render(<MemoryRouter initialEntries={['/course/abc123']}>{component}</MemoryRouter>);
|
||||
|
||||
const spinner = screen.getByRole('status');
|
||||
|
||||
@@ -185,7 +186,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 button');
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
|
||||
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
|
||||
|
||||
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
|
||||
@@ -413,10 +414,10 @@ describe('CoursewareContainer', () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
const container = await waitFor(() => loadContainer());
|
||||
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation a, nav.sequence-navigation button');
|
||||
const sequenceNextButton = sequenceNavButtons[4];
|
||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||
fireEvent.click(sequenceNavButtons[4]);
|
||||
fireEvent.click(sequenceNextButton);
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
||||
});
|
||||
|
||||
@@ -1,56 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import { PageWrap } 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 = () => {
|
||||
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..."
|
||||
/>
|
||||
)}
|
||||
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..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
<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>
|
||||
);
|
||||
|
||||
export default CoursewareRedirectLandingPage;
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Router } from 'react-router';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { MemoryRouter as Router } from 'react-router-dom';
|
||||
import { render, initializeMockApp } from '../setupTest';
|
||||
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
|
||||
|
||||
const redirectUrl = jest.fn();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useRouteMatch: () => ({
|
||||
path: '/redirect',
|
||||
}),
|
||||
}));
|
||||
jest.mock('../decode-page-route', () => jest.fn(({ children }) => <div>{children}</div>));
|
||||
|
||||
describe('CoursewareRedirectLandingPage', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -23,12 +16,8 @@ describe('CoursewareRedirectLandingPage', () => {
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router history={history}>
|
||||
<Router initialEntries={['/consent/?consentPath=%2Fgrant_data_sharing_consent']}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
@@ -37,12 +26,8 @@ describe('CoursewareRedirectLandingPage', () => {
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router history={history}>
|
||||
<Router initialEntries={['/home/course-v1:edX+DemoX+Demo_Course']}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
45
src/courseware/RedirectPage.jsx
Normal file
45
src/courseware/RedirectPage.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
69
src/courseware/RedirectPage.test.jsx
Normal file
69
src/courseware/RedirectPage.test.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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`);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -91,7 +92,16 @@ const Course = ({
|
||||
unitId={unitId}
|
||||
/>
|
||||
{shouldDisplayTriggers && (
|
||||
<SidebarTriggers />
|
||||
<>
|
||||
<Chat
|
||||
enabled={course.learningAssistantEnabled}
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||
/>
|
||||
<SidebarTriggers />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ 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
|
||||
@@ -49,8 +53,7 @@ describe('Course', () => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
const setupDiscussionSidebar = async (storageValue = false) => {
|
||||
localStorage.clear();
|
||||
const setupDiscussionSidebar = async () => {
|
||||
const testStore = await initializeTestStore({ provider: 'openedx' });
|
||||
const state = testStore.getState();
|
||||
const { courseware: { courseId } } = state;
|
||||
@@ -65,14 +68,12 @@ describe('Course', () => {
|
||||
mockData.unitId = firstUnitId;
|
||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
||||
mockData.sequenceId = firstSequenceId;
|
||||
if (storageValue !== null) {
|
||||
localStorage.setItem('showDiscussionSidebar', storageValue);
|
||||
}
|
||||
await render(<Course {...mockData} />, { store: testStore });
|
||||
|
||||
await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
};
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
@@ -105,7 +106,7 @@ describe('Course', () => {
|
||||
};
|
||||
// Set up LocalStorage for testing.
|
||||
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const firstSectionCelebrationModal = screen.getByRole('dialog');
|
||||
expect(firstSectionCelebrationModal).toBeInTheDocument();
|
||||
@@ -123,7 +124,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
|
||||
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
|
||||
@@ -131,31 +132,71 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays notification trigger and toggles active class on click', async () => {
|
||||
localStorage.setItem('showDiscussionSidebar', false);
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
|
||||
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);
|
||||
});
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('showDiscussionSidebar', false);
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
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 })).not.toHaveClass('d-none');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
sessionStorage.clear();
|
||||
render(<Course {...mockData} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
@@ -174,13 +215,12 @@ 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} />);
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
fireEvent.click(notificationShowButton);
|
||||
@@ -208,7 +248,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
@@ -217,34 +257,6 @@ 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();
|
||||
@@ -268,12 +280,12 @@ describe('Course', () => {
|
||||
previousSequenceHandler,
|
||||
unitNavigationHandler,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
|
||||
|
||||
// We are in the middle of the sequence, so no
|
||||
expect(previousSequenceHandler).not.toHaveBeenCalled();
|
||||
@@ -297,7 +309,7 @@ describe('Course', () => {
|
||||
courseId: courseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -331,7 +343,7 @@ describe('Course', () => {
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -365,7 +377,7 @@ describe('Course', () => {
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,80 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } 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 { SelectMenu } from '@edx/paragon';
|
||||
import { useToggle, ModalPopup, Menu } 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 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);
|
||||
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',
|
||||
}}
|
||||
<li
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
data-testid="breadcrumb-item"
|
||||
>
|
||||
{ getConfig().ENABLE_JUMPNAV !== 'true' || content.length < 2 || !isStaff
|
||||
? (
|
||||
<Link
|
||||
className="text-primary-500"
|
||||
to={defaultContent.sequences.length
|
||||
{showRegularLink ? (
|
||||
<Link
|
||||
className="text-primary-500"
|
||||
to={
|
||||
defaultContent.sequences.length
|
||||
? `/course/${courseId}/${defaultContent.sequences[0].id}`
|
||||
: `/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>
|
||||
)}
|
||||
|
||||
: `/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>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
@@ -87,14 +110,21 @@ 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 = [];
|
||||
@@ -108,7 +138,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,
|
||||
@@ -124,11 +154,12 @@ 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
|
||||
@@ -138,7 +169,7 @@ const CourseBreadcrumbs = ({
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
{links.map(content => (
|
||||
{links.map((content) => (
|
||||
<CourseBreadcrumb
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
|
||||
@@ -26,6 +26,12 @@ 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') {
|
||||
@@ -123,6 +129,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.queryAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.queryAllByTestId('breadcrumb-item')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { MenuItem } from '@edx/paragon';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
sendTrackingLogEvent,
|
||||
sendTrackEvent,
|
||||
} from '@edx/frontend-platform/analytics';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const JumpNavMenuItem = ({
|
||||
title,
|
||||
@@ -15,7 +15,10 @@ const JumpNavMenuItem = ({
|
||||
currentUnit,
|
||||
sequences,
|
||||
isDefault,
|
||||
onClick,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function logEvent(targetUrl) {
|
||||
const eventName = 'edx.ui.lms.jump_nav.selected';
|
||||
const payload = {
|
||||
@@ -34,19 +37,20 @@ const JumpNavMenuItem = ({
|
||||
}
|
||||
return `/course/${courseId}/${sequences[0].id}`;
|
||||
}
|
||||
function handleClick() {
|
||||
function handleClick(e) {
|
||||
const url = destinationUrl();
|
||||
logEvent(url);
|
||||
history.push(url);
|
||||
navigate(url);
|
||||
if (onClick) { onClick(e); }
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
defaultSelected={isDefault}
|
||||
onClick={() => handleClick()}
|
||||
<Dropdown.Item
|
||||
active={isDefault}
|
||||
onClick={e => handleClick(e)}
|
||||
>
|
||||
{title}
|
||||
</MenuItem>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,6 +58,10 @@ const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
});
|
||||
|
||||
JumpNavMenuItem.defaultProps = {
|
||||
onClick: null,
|
||||
};
|
||||
|
||||
JumpNavMenuItem.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
sequences: PropTypes.arrayOf(sequenceShape).isRequired,
|
||||
@@ -61,6 +69,7 @@ JumpNavMenuItem.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
currentSequence: PropTypes.string.isRequired,
|
||||
currentUnit: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default JumpNavMenuItem;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -22,12 +23,15 @@ const mockData = {
|
||||
},
|
||||
],
|
||||
isDefault: false,
|
||||
onClick: jest.fn().mockName('onClick'),
|
||||
};
|
||||
describe('JumpNavMenuItem', () => {
|
||||
render(
|
||||
<JumpNavMenuItem
|
||||
{...mockData}
|
||||
/>,
|
||||
<BrowserRouter>
|
||||
<JumpNavMenuItem
|
||||
{...mockData}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
it('renders menu Item as expected with button and Text and handles clicks', () => {
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(1);
|
||||
|
||||
76
src/courseware/course/chat/Chat.jsx
Normal file
76
src/courseware/course/chat/Chat.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
157
src/courseware/course/chat/Chat.test.jsx
Normal file
157
src/courseware/course/chat/Chat.test.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
1
src/courseware/course/chat/index.js
Normal file
1
src/courseware/course/chat/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Chat';
|
||||
@@ -1,23 +1,32 @@
|
||||
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,
|
||||
}) => (
|
||||
<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>
|
||||
);
|
||||
}) => {
|
||||
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>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
ContentTools.propTypes = {
|
||||
course: PropTypes.shape({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 { Redirect } from 'react-router-dom';
|
||||
import { Navigate } 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 (<Redirect to={`/course/${courseId}`} />);
|
||||
return (<Navigate to={`/course/${courseId}`} replace />);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('Course Exit Pages', () => {
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||||
render(component, { store });
|
||||
render(component, { store, wrapWithRouter: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
|
||||
@@ -139,34 +138,28 @@ 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 w-100">
|
||||
<div className="sequence-container d-inline-flex flex-row">
|
||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<div className="sequence-navigation-container">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||
</div>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
@@ -188,7 +181,6 @@ const Sequence = ({
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,10 @@ 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;
|
||||
@@ -42,10 +46,14 @@ 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 });
|
||||
render(
|
||||
<Sequence {...mockData} {...{ unitId: undefined, sequenceId: undefined }} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
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 () => {
|
||||
@@ -70,12 +78,14 @@ describe('Sequence', () => {
|
||||
}, false);
|
||||
const { container } = render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(5);
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Active` and `Next` buttons.
|
||||
expect(screen.getAllByRole('link').length).toEqual(2);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
@@ -101,7 +111,7 @@ describe('Sequence', () => {
|
||||
}, false);
|
||||
render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -112,26 +122,30 @@ 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 });
|
||||
render(<Sequence {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles loading unit', async () => {
|
||||
render(<Sequence {...mockData} />);
|
||||
render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// Renders navigation buttons plus one button for each unit.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4 + unitBlocks.length);
|
||||
// `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);
|
||||
|
||||
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|next/i }).length).toEqual(4);
|
||||
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
@@ -160,10 +174,10 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[1].id,
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequencePreviousButton = screen.getByRole('button', { name: /previous/i });
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
fireEvent.click(sequencePreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
@@ -176,7 +190,7 @@ describe('Sequence', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i })
|
||||
const unitPreviousButton = screen.getAllByRole('link', { name: /previous/i })
|
||||
.filter(button => button !== sequencePreviousButton)[0];
|
||||
fireEvent.click(unitPreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
@@ -196,10 +210,10 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequenceNextButton = screen.getByRole('button', { name: /next/i });
|
||||
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
|
||||
fireEvent.click(sequenceNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', {
|
||||
@@ -211,7 +225,7 @@ describe('Sequence', () => {
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
const unitNextButton = screen.getAllByRole('button', { name: /next/i })
|
||||
const unitNextButton = screen.getAllByRole('link', { name: /next/i })
|
||||
.filter(button => button !== sequenceNextButton)[0];
|
||||
fireEvent.click(unitNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
@@ -234,14 +248,14 @@ describe('Sequence', () => {
|
||||
previousSequenceHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
fireEvent.click(screen.getByRole('link', { 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`.
|
||||
@@ -258,7 +272,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -277,7 +291,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -319,15 +333,15 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
|
||||
render(<Sequence {...testData} />, { store: innerTestStore });
|
||||
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
|
||||
@@ -367,10 +381,10 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
unitNavigationHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore });
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name }));
|
||||
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', {
|
||||
current_tab: currentTabNumber,
|
||||
@@ -394,13 +408,13 @@ describe('Sequence', () => {
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />);
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||
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 }} />);
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
@@ -408,7 +422,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} />);
|
||||
const { container } = render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
// 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');
|
||||
});
|
||||
|
||||
@@ -19,13 +19,13 @@ describe('Sequence Content', () => {
|
||||
});
|
||||
|
||||
it('displays loading message', () => {
|
||||
render(<SequenceContent {...mockData} />);
|
||||
render(<SequenceContent {...mockData} />, { wrapWithRouter: true });
|
||||
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 />);
|
||||
const { container } = render(<SequenceContent {...mockData} gated />, { wrapWithRouter: true });
|
||||
|
||||
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} />);
|
||||
render(<SequenceContent {...mockData} unitId={null} />, { wrapWithRouter: true });
|
||||
expect(screen.getByText('There is no content here.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { Modal } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { processEvent } from '../../../course-home/data/thunks';
|
||||
import { useEventListener } from '../../../generic/hooks';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { fetchCourse } from '../../data';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import ShareButton from '../share/ShareButton';
|
||||
import messages from './messages';
|
||||
|
||||
const HonorCode = React.lazy(() => import('./honor-code'));
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||
);
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||
* state.
|
||||
*
|
||||
* We were able to solve this error by using a layout effect to update some component state, which
|
||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||
*
|
||||
* If we remove this hook when one of these happens:
|
||||
* 1. React figures out that there's an issue here and fixes a bug.
|
||||
* 2. We cease to use an iframe for unit rendering.
|
||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||
* 4. We stop supporting Firefox.
|
||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||
* so we can fix it.
|
||||
*
|
||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||
*/
|
||||
function useLoadBearingHook(id) {
|
||||
const setValue = useState(0)[1];
|
||||
useLayoutEffect(() => {
|
||||
setValue(currentValue => currentValue + 1);
|
||||
}, [id]);
|
||||
}
|
||||
|
||||
export function sendUrlHashToFrame(frame) {
|
||||
const { hash } = window.location;
|
||||
if (hash) {
|
||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||
// hash within the iframe.
|
||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
const Unit = ({
|
||||
courseId,
|
||||
format,
|
||||
onLoaded,
|
||||
id,
|
||||
intl,
|
||||
}) => {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
if (format) {
|
||||
iframeUrl += `&format=${format}`;
|
||||
}
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
const [windowTopOffset, setWindowTopOffset] = useState(null);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
userNeedsIntegritySignature,
|
||||
} = course;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (userNeedsIntegritySignature && unit.graded) {
|
||||
setShouldDisplayHonorCode(true);
|
||||
} else {
|
||||
setShouldDisplayHonorCode(false);
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
const receiveMessage = useCallback(({ data }) => {
|
||||
const {
|
||||
type,
|
||||
payload,
|
||||
} = data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
|
||||
// We observe exit from the video xblock full screen mode
|
||||
// and do page scroll to the previously saved scroll position
|
||||
if (windowTopOffset !== null) {
|
||||
window.scrollTo(0, Number(windowTopOffset));
|
||||
}
|
||||
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (type === 'plugin.videoFullScreen') {
|
||||
// We listen for this message from LMS to know when we need to
|
||||
// save or reset scroll position on toggle video xblock full screen mode.
|
||||
setWindowTopOffset(payload.open ? window.scrollY : null);
|
||||
} else if (data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded, setWindowTopOffset, windowTopOffset]);
|
||||
useEventListener('message', receiveMessage);
|
||||
useEffect(() => {
|
||||
sendUrlHashToFrame(document.getElementById('unit-iframe'));
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
{/* TODO: social share exp. Need to remove later */}
|
||||
{(window.expSocialShareAboutUrls && window.expSocialShareAboutUrls[unit.id] !== undefined) && (
|
||||
<ShareButton url={window.expSocialShareAboutUrls[unit.id]} />
|
||||
)}
|
||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loadingHonorCode)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<HonorCode courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{!shouldDisplayHonorCode && !hasLoaded && !showError && (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||
/>
|
||||
)}
|
||||
{!shouldDisplayHonorCode && !hasLoaded && showError && (
|
||||
<ErrorPage />
|
||||
)}
|
||||
{modalOptions.open && (
|
||||
<Modal
|
||||
body={(
|
||||
<>
|
||||
{modalOptions.body
|
||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||
: (
|
||||
<iframe
|
||||
title={modalOptions.title}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.url}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
onClose={() => { setModalOptions({ open: false }); }}
|
||||
open
|
||||
dialogClassName="modal-lti"
|
||||
/>
|
||||
)}
|
||||
{!shouldDisplayHonorCode && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id="unit-iframe"
|
||||
title={unit.title}
|
||||
src={iframeUrl}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
onLoad={() => {
|
||||
// onLoad *should* only fire after everything in the iframe has finished its own load events.
|
||||
// Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
||||
// for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
||||
// could have given us a 4xx or 5xx response.
|
||||
if (!hasLoaded) {
|
||||
setShowError(true);
|
||||
}
|
||||
|
||||
window.onmessage = (e) => {
|
||||
if (e.data.event_name) {
|
||||
dispatch(processEvent(e.data, fetchCourse));
|
||||
}
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Unit.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
format: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
format: null,
|
||||
onLoaded: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(Unit);
|
||||
@@ -1,192 +0,0 @@
|
||||
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('scrolls page on MessagaeEvent when receiving videoFullScreen state', async () => {
|
||||
// Set message to constain video full screen data.
|
||||
const defaultTopOffset = 800;
|
||||
const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 500 } };
|
||||
const testMessageWithFullscreenState = (isOpen) => ({ type: 'plugin.videoFullScreen', payload: { open: isOpen } });
|
||||
render(<Unit {...mockData} />);
|
||||
Object.defineProperty(window, 'scrollY', { value: defaultTopOffset, writable: true });
|
||||
window.postMessage(testMessageWithFullscreenState(true), '*');
|
||||
window.postMessage(testMessageWithFullscreenState(false), '*');
|
||||
window.postMessage(testMessageWithOtherHeight, '*');
|
||||
|
||||
await expect(waitFor(() => expect(window.scrollTo()).toHaveBeenCalledTimes(1)));
|
||||
expect(window.scrollY === defaultTopOffset);
|
||||
});
|
||||
|
||||
it('ignores MessageEvent with unhandled type', async () => {
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
114
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
114
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
export const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *, clipboard-write *'
|
||||
);
|
||||
|
||||
export const testIDs = StrictDict({
|
||||
contentIFrame: 'content-iframe-test-id',
|
||||
modalIFrame: 'modal-iframe-test-id',
|
||||
});
|
||||
|
||||
const ContentIFrame = ({
|
||||
iframeUrl,
|
||||
shouldShowContent,
|
||||
loadingMessage,
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
}) => {
|
||||
const {
|
||||
handleIFrameLoad,
|
||||
hasLoaded,
|
||||
iframeHeight,
|
||||
showError,
|
||||
} = hooks.useIFrameBehavior({
|
||||
elementId,
|
||||
id,
|
||||
iframeUrl,
|
||||
onLoaded,
|
||||
});
|
||||
|
||||
const {
|
||||
modalOptions,
|
||||
handleModalClose,
|
||||
} = hooks.useModalIFrameData();
|
||||
|
||||
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
|
||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||
: (
|
||||
<iframe
|
||||
title={modalOptions.title}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.url}
|
||||
style={{ width: '100%', height: modalOptions.height }}
|
||||
/>
|
||||
)}
|
||||
dialogClassName="modal-lti"
|
||||
onClose={handleModalClose}
|
||||
open
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ContentIFrame.propTypes = {
|
||||
iframeUrl: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
shouldShowContent: PropTypes.bool.isRequired,
|
||||
loadingMessage: PropTypes.node.isRequired,
|
||||
elementId: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
title: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
ContentIFrame.defaultProps = {
|
||||
iframeUrl: null,
|
||||
onLoaded: () => ({}),
|
||||
};
|
||||
|
||||
export default ContentIFrame;
|
||||
175
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
175
src/courseware/course/sequence/Unit/ContentIFrame.test.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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 }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/courseware/course/sequence/Unit/UnitSuspense.jsx
Normal file
50
src/courseware/course/sequence/Unit/UnitSuspense.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
106
src/courseware/course/sequence/Unit/UnitSuspense.test.jsx
Normal file
106
src/courseware/course/sequence/Unit/UnitSuspense.test.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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>
|
||||
`;
|
||||
26
src/courseware/course/sequence/Unit/constants.js
Normal file
26
src/courseware/course/sequence/Unit/constants.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { StrictDict } from '@edx/react-unit-test-utils/dist';
|
||||
|
||||
export const modelKeys = StrictDict({
|
||||
units: 'units',
|
||||
coursewareMeta: 'coursewareMeta',
|
||||
});
|
||||
|
||||
export const views = StrictDict({
|
||||
student: 'student_view',
|
||||
public: 'public_view',
|
||||
});
|
||||
|
||||
export const loadingState = 'loading';
|
||||
|
||||
export const messageTypes = StrictDict({
|
||||
modal: 'plugin.modal',
|
||||
resize: 'plugin.resize',
|
||||
videoFullScreen: 'plugin.videoFullScreen',
|
||||
});
|
||||
|
||||
export default StrictDict({
|
||||
modelKeys,
|
||||
views,
|
||||
loadingState,
|
||||
messageTypes,
|
||||
});
|
||||
5
src/courseware/course/sequence/Unit/hooks/index.js
Normal file
5
src/courseware/course/sequence/Unit/hooks/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
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';
|
||||
37
src/courseware/course/sequence/Unit/hooks/useExamAccess.js
Normal file
37
src/courseware/course/sequence/Unit/hooks/useExamAccess.js
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
@@ -0,0 +1,98 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
116
src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Normal file
116
src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js
Normal file
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
@@ -0,0 +1,295 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
@@ -0,0 +1,24 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
@@ -0,0 +1,68 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/courseware/course/sequence/Unit/index.jsx
Normal file
73
src/courseware/course/sequence/Unit/index.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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 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';
|
||||
|
||||
const Unit = ({
|
||||
courseId,
|
||||
format,
|
||||
onLoaded,
|
||||
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,
|
||||
format,
|
||||
examAccess,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
<UnitSuspense {...{ courseId, id }} />
|
||||
<ContentIFrame
|
||||
elementId="unit-iframe"
|
||||
id={id}
|
||||
iframeUrl={iframeUrl}
|
||||
loadingMessage={formatMessage(messages.loadingSequence)}
|
||||
onLoaded={onLoaded}
|
||||
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
|
||||
title={unit.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Unit.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
format: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
format: null,
|
||||
onLoaded: undefined,
|
||||
};
|
||||
|
||||
export default Unit;
|
||||
191
src/courseware/course/sequence/Unit/index.test.jsx
Normal file
191
src/courseware/course/sequence/Unit/index.test.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/courseware/course/sequence/Unit/urls.js
Normal file
28
src/courseware/course/sequence/Unit/urls.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
};
|
||||
42
src/courseware/course/sequence/Unit/urls.test.js
Normal file
42
src/courseware/course/sequence/Unit/urls.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,8 +11,9 @@ import messages from './messages';
|
||||
const ContentLock = ({
|
||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = useCallback(() => {
|
||||
history.push(`/course/${courseId}/${prereqId}`);
|
||||
navigate(`/course/${courseId}/${prereqId}`);
|
||||
}, [courseId, prereqId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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',
|
||||
@@ -19,7 +25,7 @@ describe('Content Lock', () => {
|
||||
});
|
||||
|
||||
it('displays sequence title along with lock icon', () => {
|
||||
const { container } = render(<ContentLock {...mockData} />);
|
||||
const { container } = render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
const lockIcon = container.querySelector('svg');
|
||||
expect(lockIcon).toHaveClass('fa-lock');
|
||||
@@ -28,16 +34,15 @@ describe('Content Lock', () => {
|
||||
|
||||
it('displays prerequisite name', () => {
|
||||
const prereqText = `You must complete the prerequisite: '${mockData.prereqSectionName}' to access this content.`;
|
||||
render(<ContentLock {...mockData} />);
|
||||
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
expect(screen.getByText(prereqText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click', () => {
|
||||
history.push = jest.fn();
|
||||
render(<ContentLock {...mockData} />);
|
||||
render(<ContentLock {...mockData} />, { wrapWithRouter: true });
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getConfig } 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,
|
||||
@@ -20,7 +22,7 @@ const HonorCode = ({ intl, courseId }) => {
|
||||
const siteName = getConfig().SITE_NAME;
|
||||
const honorCodeUrl = `${getConfig().TERMS_OF_SERVICE_URL}#honor-code`;
|
||||
|
||||
const handleCancel = () => history.push(`/course/${courseId}/home`);
|
||||
const handleCancel = () => navigate(`/course/${courseId}/home`);
|
||||
|
||||
const handleAgree = () => dispatch(
|
||||
// If the request is made by a staff user masquerading as a specific learner,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getConfig } 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('@edx/frontend-platform', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform'),
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
describe('Honor Code', () => {
|
||||
@@ -38,15 +38,15 @@ describe('Honor Code', () => {
|
||||
|
||||
it('cancel button links to course home ', async () => {
|
||||
await setupStoreState();
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
});
|
||||
|
||||
it('calls to save integrity_signature when agreeing', async () => {
|
||||
await setupStoreState({ username: authenticatedUser.username });
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
@@ -63,7 +63,7 @@ describe('Honor Code', () => {
|
||||
username: authenticatedUser.username,
|
||||
},
|
||||
);
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
@@ -80,7 +80,7 @@ describe('Honor Code', () => {
|
||||
username: 'otheruser',
|
||||
},
|
||||
);
|
||||
render(<HonorCode {...mockData} />);
|
||||
render(<HonorCode {...mockData} />, { wrapWithRouter: true });
|
||||
const agreeButton = screen.getByText('I agree');
|
||||
fireEvent.click(agreeButton);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -26,12 +27,13 @@ const SequenceNavigation = ({
|
||||
sequenceId,
|
||||
className,
|
||||
onNavigate,
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
goToCourseExitPage,
|
||||
nextHandler,
|
||||
previousHandler,
|
||||
}) => {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
courseId,
|
||||
sequenceStatus,
|
||||
@@ -63,27 +65,49 @@ 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={buttonOnClick} disabled={disabled} iconAfter={nextArrow}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="next-btn"
|
||||
onClick={nextHandler}
|
||||
disabled={disabled}
|
||||
iconAfter={nextArrow}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft;
|
||||
|
||||
return sequenceStatus === LOADED && (
|
||||
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={prevArrow}>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : intl.formatMessage(messages.previousButton)}
|
||||
</Button>
|
||||
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className)} style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}>
|
||||
{renderPreviousButton()}
|
||||
{renderUnitButtons()}
|
||||
{renderNextButton()}
|
||||
|
||||
@@ -97,9 +121,8 @@ SequenceNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
nextHandler: PropTypes.func.isRequired,
|
||||
previousHandler: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
|
||||
@@ -25,22 +25,21 @@ describe('Sequence Navigation', () => {
|
||||
mockData = {
|
||||
unitId: unitBlocks[1].id,
|
||||
sequenceId: courseware.sequenceId,
|
||||
previousSequenceHandler: () => {},
|
||||
previousHandler: () => {},
|
||||
onNavigate: () => {},
|
||||
nextSequenceHandler: () => {},
|
||||
goToCourseExitPage: () => {},
|
||||
nextHandler: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('is empty while loading', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
|
||||
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore });
|
||||
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders empty div without unitId', () => {
|
||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />);
|
||||
const { container } = render(<SequenceNavigation {...mockData} unitId={undefined} />, { wrapWithRouter: true });
|
||||
expect(getByText(container, (content, element) => (
|
||||
element.tagName.toLowerCase() === 'div' && element.getAttribute('style')))).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -62,7 +61,7 @@ describe('Sequence Navigation', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
onNavigate: jest.fn(),
|
||||
};
|
||||
render(<SequenceNavigation {...testData} />, { store: testStore });
|
||||
render(<SequenceNavigation {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
|
||||
const unitButton = screen.getByTitle(unitBlocks[1].display_name);
|
||||
fireEvent.click(unitButton);
|
||||
@@ -75,27 +74,27 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
it('renders correctly and handles unit button clicks', () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />);
|
||||
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
|
||||
|
||||
const unitButtons = screen.getAllByRole('button', { name: /\d+/ });
|
||||
const unitButtons = screen.getAllByRole('link', { 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} />);
|
||||
render(<SequenceNavigation {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
screen.getAllByRole('button', { name: /previous|next/i }).forEach(button => {
|
||||
screen.getAllByRole('link', { 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} />);
|
||||
render(<SequenceNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('has the "Next" button disabled for the last unit of the sequence if there is no Exit page', async () => {
|
||||
@@ -107,10 +106,10 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
render(
|
||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -123,11 +122,11 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
render(
|
||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||
@@ -144,22 +143,22 @@ describe('Sequence Navigation', () => {
|
||||
|
||||
render(
|
||||
<SequenceNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('handles "Previous" and "Next" click', () => {
|
||||
const previousSequenceHandler = jest.fn();
|
||||
const nextSequenceHandler = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ previousSequenceHandler, nextSequenceHandler }} />);
|
||||
const previousHandler = jest.fn();
|
||||
const nextHandler = jest.fn();
|
||||
render(<SequenceNavigation {...mockData} {...{ previousHandler, nextHandler }} />, { wrapWithRouter: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
expect(previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(previousHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
expect(nextSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
expect(nextHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,14 +40,17 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
|
||||
unitBlocks.forEach((unit, index) => {
|
||||
it(`marks unit ${index + 1} as active`, async () => {
|
||||
const { container } = render(<SequenceNavigationDropdown {...mockData} unitId={unit.id} />);
|
||||
const { container } = render(
|
||||
<SequenceNavigationDropdown {...mockData} unitId={unit.id} />,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
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, 'button', { hidden: true }).forEach(button => {
|
||||
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
|
||||
if (button.textContent === unit.display_name) {
|
||||
expect(button).toHaveClass('active');
|
||||
} else {
|
||||
@@ -59,14 +62,17 @@ describe('Sequence Navigation Dropdown', () => {
|
||||
|
||||
it('handles the clicks', () => {
|
||||
const onNavigate = jest.fn();
|
||||
const { container } = render(<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />);
|
||||
const { container } = render(
|
||||
<SequenceNavigationDropdown {...mockData} onNavigate={onNavigate} />,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
|
||||
const dropdownToggle = container.querySelector('.dropdown-toggle');
|
||||
act(() => {
|
||||
fireEvent.click(dropdownToggle);
|
||||
});
|
||||
const dropdownMenu = container.querySelector('.dropdown-menu');
|
||||
getAllByRole(dropdownMenu, 'button', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
|
||||
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
|
||||
unitBlocks.forEach((unit, index) => {
|
||||
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);
|
||||
|
||||
@@ -41,16 +41,16 @@ describe('Sequence Navigation Tabs', () => {
|
||||
|
||||
it('renders unit buttons', () => {
|
||||
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
|
||||
render(<SequenceNavigationTabs {...mockData} />);
|
||||
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(unitBlocks.length);
|
||||
expect(screen.getAllByRole('link')).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} />);
|
||||
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
|
||||
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, 'button');
|
||||
expect(dropdownButtons).toHaveLength(unitBlocks.length + 1);
|
||||
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
|
||||
expect(dropdownButtons).toHaveLength(unitBlocks.length);
|
||||
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
|
||||
.toHaveClass('dropdown-toggle');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
@@ -20,6 +21,8 @@ const UnitButton = ({
|
||||
className,
|
||||
showTitle,
|
||||
}) => {
|
||||
const { courseId, sequenceId } = useSelector(state => state.courseware);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(unitId);
|
||||
}, [onClick, unitId]);
|
||||
@@ -33,6 +36,8 @@ 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>}
|
||||
|
||||
@@ -32,13 +32,13 @@ describe('Unit Button', () => {
|
||||
});
|
||||
|
||||
it('hides title by default', () => {
|
||||
render(<UnitButton {...mockData} />);
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(unit.display_name);
|
||||
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
|
||||
});
|
||||
|
||||
it('shows title', () => {
|
||||
render(<UnitButton {...mockData} showTitle />);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(unit.display_name);
|
||||
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('link')).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} />);
|
||||
const { container } = render(<UnitButton {...mockData} unitId={completedUnit.id} />, { wrapWithRouter: true });
|
||||
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} />);
|
||||
const { container } = render(<UnitButton {...mockData} unitId={bookmarkedUnit.id} />, { wrapWithRouter: true });
|
||||
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} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -20,14 +21,32 @@ const UnitNavigation = ({
|
||||
unitId,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
goToCourseExitPage,
|
||||
}) => {
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = 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;
|
||||
@@ -35,8 +54,10 @@ const UnitNavigation = ({
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
className="next-button d-flex align-items-center justify-content-center"
|
||||
onClick={buttonOnClick}
|
||||
onClick={onClickNext}
|
||||
disabled={disabled}
|
||||
as={disabled ? undefined : Link}
|
||||
to={disabled ? undefined : nextLink}
|
||||
>
|
||||
<UnitNavigationEffortEstimate sequenceId={sequenceId} unitId={unitId}>
|
||||
{buttonText}
|
||||
@@ -46,18 +67,9 @@ const UnitNavigation = ({
|
||||
);
|
||||
};
|
||||
|
||||
const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft;
|
||||
return (
|
||||
<div className="unit-navigation d-flex">
|
||||
<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>
|
||||
{renderPreviousButton()}
|
||||
{renderNextButton()}
|
||||
</div>
|
||||
);
|
||||
@@ -69,7 +81,6 @@ UnitNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
onClickPrevious: PropTypes.func.isRequired,
|
||||
onClickNext: PropTypes.func.isRequired,
|
||||
goToCourseExitPage: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UnitNavigation.defaultProps = {
|
||||
|
||||
@@ -22,7 +22,6 @@ describe('Unit Navigation', () => {
|
||||
sequenceId: courseware.sequenceId,
|
||||
onClickPrevious: () => {},
|
||||
onClickNext: () => {},
|
||||
goToCourseExitPage: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -33,10 +32,10 @@ describe('Unit Navigation', () => {
|
||||
unitId=""
|
||||
onClickPrevious={() => {}}
|
||||
onClickNext={() => {}}
|
||||
/>);
|
||||
/>, { wrapWithRouter: true });
|
||||
|
||||
// Only "Previous" and "Next" buttons should be rendered.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles the clicks', () => {
|
||||
@@ -45,32 +44,30 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(<UnitNavigation
|
||||
{...mockData}
|
||||
sequenceId=""
|
||||
unitId=""
|
||||
onClickPrevious={onClickPrevious}
|
||||
onClickNext={onClickNext}
|
||||
/>);
|
||||
/>, { wrapWithRouter: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
expect(onClickPrevious).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /next/i }));
|
||||
expect(onClickNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has the navigation buttons enabled for the non-corner unit in the sequence', () => {
|
||||
render(<UnitNavigation {...mockData} />);
|
||||
render(<UnitNavigation {...mockData} />, { wrapWithRouter: true });
|
||||
|
||||
screen.getAllByRole('button').forEach(button => {
|
||||
screen.getAllByRole('link').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} />);
|
||||
render(<UnitNavigation {...mockData} unitId={unitBlocks[0].id} />, { wrapWithRouter: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('has the "Next" button disabled for the last unit in the sequence if there is no Exit Page', async () => {
|
||||
@@ -82,10 +79,10 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -98,11 +95,11 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /next \(end of course\)/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('displays complete course message instead of the "Next" button as needed', async () => {
|
||||
@@ -119,10 +116,10 @@ describe('Unit Navigation', () => {
|
||||
|
||||
render(
|
||||
<UnitNavigation {...testData} unitId={unitBlocks[unitBlocks.length - 1].id} />,
|
||||
{ store: testStore },
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: /Complete the course/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /previous/i })).toBeEnabled();
|
||||
expect(screen.getByRole('link', { name: /Complete the course/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ 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);
|
||||
|
||||
@@ -14,12 +15,43 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId)
|
||||
if (courseStatus !== 'loaded' || sequenceStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
||||
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
|
||||
|
||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||
|
||||
const isFirstSequence = sequenceIndex === 0;
|
||||
const isFirstUnitInSequence = unitIndex === 0;
|
||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
|
||||
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
|
||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||
|
||||
return { isFirstUnit, isLastUnit };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ 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';
|
||||
|
||||
@@ -15,32 +13,18 @@ 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);
|
||||
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 initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
|
||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
useEffect(() => {
|
||||
// As a one-off set initial sidebar if the verified mode data has just loaded
|
||||
if (verifiedMode && currentSidebar === null && initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
setCurrentSidebar(SIDEBARS.DISCUSSIONS.ID);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialSidebar, verifiedMode]);
|
||||
}, [unitId]);
|
||||
|
||||
const onNotificationSeen = useCallback(() => {
|
||||
setNotificationStatus('inactive');
|
||||
@@ -49,11 +33,6 @@ 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]);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext } from 'react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
|
||||
@@ -9,9 +8,6 @@ const SidebarTriggers = () => {
|
||||
toggleSidebar,
|
||||
currentSidebar,
|
||||
} = useContext(SidebarContext);
|
||||
|
||||
const isMobileView = useWindowSize().width < breakpoints.small.minWidth;
|
||||
|
||||
return (
|
||||
<div className="d-flex ml-auto">
|
||||
{SIDEBAR_ORDER.map((sidebarId) => {
|
||||
@@ -19,7 +15,7 @@ const SidebarTriggers = () => {
|
||||
const isActive = sidebarId === currentSidebar;
|
||||
return (
|
||||
<div
|
||||
className={classNames({ 'mt-3': !isMobileView, 'border-primary-700': isActive })}
|
||||
className={classNames('mt-3', { 'border-primary-700': isActive })}
|
||||
style={{ borderBottom: isActive ? '2px solid' : null }}
|
||||
key={sidebarId}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ const SidebarTriggerBase = ({
|
||||
children,
|
||||
}) => (
|
||||
<button
|
||||
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex notification-btn"
|
||||
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -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 } from 'react';
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
||||
@@ -23,12 +23,16 @@ 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(() => {
|
||||
// Only fetch the topic data if the MFE is configured.
|
||||
if (baseUrl) {
|
||||
if (baseUrl && edxProvider) {
|
||||
dispatch(getCourseDiscussionTopics(courseId));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -57,4 +57,5 @@ Factory.define('courseMetadata')
|
||||
related_programs: null,
|
||||
user_needs_integrity_signature: false,
|
||||
recommendations: null,
|
||||
learning_assistant_enabled: null,
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
} = Matchers;
|
||||
const provider = new Pact({
|
||||
} = MatchersV3;
|
||||
const provider = new PactV3({
|
||||
consumer: 'frontend-app-learning',
|
||||
provider: 'lms',
|
||||
log: path.resolve(process.cwd(), 'src/courseware/data/pact-tests/logs', 'pact.log'),
|
||||
@@ -33,16 +33,11 @@ const provider = new Pact({
|
||||
describe('Courseware Service', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
await provider
|
||||
.setup()
|
||||
.then((options) => mergeConfig({
|
||||
LMS_BASE_URL: `http://localhost:${options.port}`,
|
||||
}, 'Custom app config for pact tests'));
|
||||
mergeConfig({
|
||||
LMS_BASE_URL: 'http://localhost:8081',
|
||||
}, '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 () => {
|
||||
const normalizedOutline = {
|
||||
@@ -233,6 +228,7 @@ describe('Courseware Service', () => {
|
||||
linkedinAddToProfileUrl: null,
|
||||
relatedPrograms: null,
|
||||
userNeedsIntegritySignature: false,
|
||||
learningAssistantEnabled: false,
|
||||
};
|
||||
setTimeout(() => {
|
||||
provider.addInteraction({
|
||||
@@ -338,6 +334,7 @@ describe('Courseware Service', () => {
|
||||
verification_status: string('none'),
|
||||
linkedin_add_to_profile_url: null,
|
||||
user_needs_integrity_signature: boolean(false),
|
||||
learning_assistant_enabled: boolean(false),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
22
src/courseware/utils.jsx
Normal file
22
src/courseware/utils.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
|
||||
<div>
|
||||
PageRoute: {
|
||||
"computedMatch": {
|
||||
"path": "/course/:courseId/home",
|
||||
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
|
||||
"isExact": true,
|
||||
"params": {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course"
|
||||
}
|
||||
}
|
||||
PageWrap: {
|
||||
"children": [
|
||||
" ",
|
||||
[
|
||||
" ",
|
||||
[],
|
||||
" "
|
||||
],
|
||||
" "
|
||||
]
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import { PageWrap } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
import { useHistory, generatePath } from 'react-router';
|
||||
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])),
|
||||
);
|
||||
|
||||
export const decodeUrl = (encodedUrl) => {
|
||||
const decodedUrl = decodeURIComponent(encodedUrl);
|
||||
@@ -11,10 +19,16 @@ export const decodeUrl = (encodedUrl) => {
|
||||
return decodeUrl(decodedUrl);
|
||||
};
|
||||
|
||||
const DecodePageRoute = (props) => {
|
||||
const history = useHistory();
|
||||
if (props.computedMatch) {
|
||||
const { url, path, params } = props.computedMatch;
|
||||
const DecodePageRoute = ({ children }) => {
|
||||
let computedMatch = null;
|
||||
|
||||
ROUTES.forEach((route) => {
|
||||
const matchedRoute = useMatch(route);
|
||||
if (matchedRoute) { computedMatch = matchedRoute; }
|
||||
});
|
||||
|
||||
if (computedMatch) {
|
||||
const { pathname, pattern, params } = computedMatch;
|
||||
|
||||
Object.keys(params).forEach((param) => {
|
||||
// only decode params not the entire url.
|
||||
@@ -22,28 +36,19 @@ const DecodePageRoute = (props) => {
|
||||
params[param] = decodeUrl(params[param]);
|
||||
});
|
||||
|
||||
const newUrl = generatePath(path, params);
|
||||
const newUrl = generatePath(pattern.path, params);
|
||||
|
||||
// if the url get decoded, reroute to the decoded url
|
||||
if (newUrl !== url) {
|
||||
history.replace(newUrl);
|
||||
if (newUrl !== pathname) {
|
||||
return <Navigate to={newUrl} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <PageRoute {...props} />;
|
||||
return <PageWrap> {children} </PageWrap>;
|
||||
};
|
||||
|
||||
DecodePageRoute.propTypes = {
|
||||
computedMatch: PropTypes.shape({
|
||||
url: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
params: PropTypes.any,
|
||||
}),
|
||||
};
|
||||
|
||||
DecodePageRoute.defaultProps = {
|
||||
computedMatch: null,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default DecodePageRoute;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { Router, matchPath } from 'react-router';
|
||||
import {
|
||||
MemoryRouter as Router, matchPath, Routes, Route, mockNavigate,
|
||||
} from 'react-router-dom';
|
||||
import DecodePageRoute, { decodeUrl } from '.';
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
@@ -15,84 +16,90 @@ const deepEncodedCourseId = (() => {
|
||||
})();
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
|
||||
PageWrap: (props) => `PageWrap: ${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`,
|
||||
},
|
||||
}));
|
||||
|
||||
const renderPage = (props) => {
|
||||
const memHistory = createMemoryHistory({
|
||||
initialEntries: [props?.path],
|
||||
});
|
||||
jest.mock('react-router-dom', () => {
|
||||
const mockNavigation = jest.fn();
|
||||
|
||||
const history = {
|
||||
...memHistory,
|
||||
replace: 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 { container } = render(
|
||||
<Router history={history}>
|
||||
<DecodePageRoute computedMatch={props} />
|
||||
<Router initialEntries={[props?.pathname]}>
|
||||
<Routes>
|
||||
<Route path={props?.pattern?.path} element={<DecodePageRoute> {[]} </DecodePageRoute>} />
|
||||
</Routes>
|
||||
</Router>,
|
||||
);
|
||||
|
||||
return {
|
||||
container,
|
||||
history,
|
||||
props,
|
||||
};
|
||||
return { container };
|
||||
};
|
||||
|
||||
describe('DecodePageRoute', () => {
|
||||
it('should not modify the url if it does not need to be decoded', () => {
|
||||
const props = matchPath(`/course/${decodedCourseId}/home`, {
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { container, history } = renderPage(props);
|
||||
afterEach(() => {
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
expect(props.url).toContain(decodedCourseId);
|
||||
expect(history.replace).not.toHaveBeenCalled();
|
||||
it('should not modify the url if it does not need to be decoded', () => {
|
||||
const props = matchPath({
|
||||
path: '/course/:courseId/home',
|
||||
}, `/course/${decodedCourseId}/home`);
|
||||
const { container } = renderPage(props);
|
||||
|
||||
expect(props.pathname).toContain(decodedCourseId);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should decode the url and replace the history if necessary', () => {
|
||||
const props = matchPath(`/course/${encodedCourseId}/home`, {
|
||||
const props = matchPath({
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
}, `/course/${encodedCourseId}/home`);
|
||||
renderPage(props);
|
||||
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(encodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
expect(props.pathname).not.toContain(decodedCourseId);
|
||||
expect(props.pathname).toContain(encodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||
});
|
||||
|
||||
it('should decode the url multiple times if necessary', () => {
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
|
||||
const props = matchPath({
|
||||
path: '/course/:courseId/home',
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
}, `/course/${deepEncodedCourseId}/home`);
|
||||
renderPage(props);
|
||||
|
||||
expect(props.url).not.toContain(decodedCourseId);
|
||||
expect(props.url).toContain(deepEncodedCourseId);
|
||||
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
|
||||
expect(props.pathname).not.toContain(decodedCourseId);
|
||||
expect(props.pathname).toContain(deepEncodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/home`);
|
||||
});
|
||||
|
||||
it('should only decode the url params and not the entire url', () => {
|
||||
const decodedUnitId = 'some+thing';
|
||||
const encodedUnitId = encodeURIComponent(decodedUnitId);
|
||||
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
|
||||
const props = matchPath({
|
||||
path: `/course/:courseId/${encodedUnitId}/:unitId`,
|
||||
});
|
||||
const { history } = renderPage(props);
|
||||
}, `/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`);
|
||||
renderPage(props);
|
||||
|
||||
const decodedUrls = history.replace.mock.calls[0][0].split('/');
|
||||
|
||||
// unitId get decoded
|
||||
expect(decodedUrls.pop()).toContain(decodedUnitId);
|
||||
|
||||
// path remain encoded
|
||||
expect(decodedUrls.pop()).toContain(encodedUnitId);
|
||||
|
||||
// courseId get decoded
|
||||
expect(decodedUrls.pop()).toContain(decodedCourseId);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(`/course/${decodedCourseId}/${encodedUnitId}/${decodedUnitId}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, Navigate } 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';
|
||||
@@ -38,7 +37,7 @@ const CourseAccessErrorPage = ({ intl }) => {
|
||||
);
|
||||
}
|
||||
if (courseStatus === LOADED) {
|
||||
return (<Redirect to={`/redirect/home/${courseId}`} />);
|
||||
return <Navigate to={`/redirect/home/${courseId}`} replace />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { Route } from 'react-router';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
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,
|
||||
@@ -14,6 +16,10 @@ 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;
|
||||
@@ -28,33 +34,36 @@ describe('CourseAccessErrorPage', () => {
|
||||
it('Displays loading in start on page rendering', () => {
|
||||
mockCourseStatus = 'loading';
|
||||
render(
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
expect(screen.getByTestId('page-loading')).toBeInTheDocument();
|
||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
||||
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||
});
|
||||
|
||||
it('Redirect user to homepage if user has access', () => {
|
||||
mockCourseStatus = 'loaded';
|
||||
render(
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
expect(history.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||
expect(window.location.pathname).toBe('/redirect/home/course-v1:edX+DemoX+Demo_Course');
|
||||
});
|
||||
|
||||
it('For access denied it should render access denied page', () => {
|
||||
mockCourseStatus = 'denied';
|
||||
|
||||
render(
|
||||
<Route path="/course/:courseId/access-denied">
|
||||
<CourseAccessErrorPage />
|
||||
</Route>,
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/access-denied" element={<CourseAccessErrorPage />} />
|
||||
</Routes>,
|
||||
{ wrapWithRouter: true },
|
||||
);
|
||||
expect(screen.getByTestId('access-denied-main')).toBeInTheDocument();
|
||||
expect(history.location.pathname).toBe(accessDeniedUrl);
|
||||
expect(window.location.pathname).toBe(accessDeniedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Redirect, useLocation } from 'react-router-dom';
|
||||
import { Navigate, 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(' ')) {
|
||||
if (location.pathname.includes(' ') || location.pathname.includes('%20')) {
|
||||
const newLocation = {
|
||||
...location,
|
||||
pathname: location.pathname.replaceAll(' ', '+'),
|
||||
pathname: (location.pathname.replaceAll(' ', '+')).replaceAll('%20', '+'),
|
||||
};
|
||||
|
||||
sendTrackEvent('edx.ui.lms.path_fixed', {
|
||||
@@ -29,7 +29,7 @@ const PathFixesProvider = ({ children }) => {
|
||||
search: location.search,
|
||||
});
|
||||
|
||||
return (<Redirect to={newLocation} />);
|
||||
return (<Navigate to={newLocation} replace />);
|
||||
}
|
||||
|
||||
return children; // pass through
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import {
|
||||
MemoryRouter, Route, Routes, useLocation,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
@@ -19,16 +21,20 @@ describe('PathFixesProvider', () => {
|
||||
});
|
||||
|
||||
function buildAndRender(path) {
|
||||
const LocationComponent = () => {
|
||||
testLocation = useLocation();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<PathFixesProvider>
|
||||
<Route
|
||||
path="*"
|
||||
render={routeProps => {
|
||||
testLocation = routeProps.location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={<LocationComponent />}
|
||||
/>
|
||||
</Routes>
|
||||
</PathFixesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ const invisibleStyle = {
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
// An additional Font Awesome stylesheet is imported by Braze in
|
||||
// stage/production but not devstack.
|
||||
.upgrade-notification-ul.fa-ul {
|
||||
padding: 0.875rem 1.25rem 0;
|
||||
margin: 0 0 1rem 2.5rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-top: 0.875rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.upgrade-notification-text {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||
import { messages as headerMessages } from '@edx/frontend-component-header';
|
||||
import { messages as paragonMessages } from '@edx/paragon';
|
||||
|
||||
import arMessages from './messages/ar.json';
|
||||
import frMessages from './messages/fr.json';
|
||||
import es419Messages from './messages/es_419.json';
|
||||
@@ -8,10 +12,14 @@ import ukMessages from './messages/uk.json';
|
||||
import deMessages from './messages/de.json';
|
||||
import ruMessages from './messages/ru.json';
|
||||
import hiMessages from './messages/hi.json';
|
||||
import faIRMessages from './messages/fa_IR.json';
|
||||
import frCAMessages from './messages/fr_CA.json';
|
||||
import dedeCAMessages from './messages/de_DE.json';
|
||||
import ititCAMessages from './messages/it_IT.json';
|
||||
import ptptCAMessages from './messages/pt_PT.json';
|
||||
// no need to import en messages-- they are in the defaultMessage field
|
||||
|
||||
const messages = {
|
||||
const appMessages = {
|
||||
ar: arMessages,
|
||||
'es-419': es419Messages,
|
||||
fr: frMessages,
|
||||
@@ -20,9 +28,18 @@ const messages = {
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
hi: hiMessages,
|
||||
'fa-ir': faIRMessages,
|
||||
'fr-ca': frCAMessages,
|
||||
ru: ruMessages,
|
||||
uk: ukMessages,
|
||||
'de-de': dedeCAMessages,
|
||||
'it-it': ititCAMessages,
|
||||
'pt-pt': ptptCAMessages,
|
||||
};
|
||||
|
||||
export default messages;
|
||||
export default [
|
||||
paragonMessages,
|
||||
appMessages,
|
||||
footerMessages,
|
||||
headerMessages,
|
||||
];
|
||||
452
src/i18n/messages/de_DE.json
Normal file
452
src/i18n/messages/de_DE.json
Normal file
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"learning.accessExpiration.deadline": "Upgraden Sie bis {date}, um unbegrenzten Zugang zu diesem Kurs zu erhalten, solange dieser auf dieser Seite existiert.",
|
||||
"learning.accessExpiration.header": "Audit-Zugriff gültig bis {date}",
|
||||
"learning.accessExpiration.body": "Sie verlieren am {date} jeglichen Zugriff auf diesen Kurs, einschließlich Ihres Fortschritts.",
|
||||
"instructorToolbar.pageBanner.courseHasExpired": "Dieser Teilnehmer hat keinen Zugriff mehr auf diesen Kurs. Der Zugriff ist am {date} abgelaufen.",
|
||||
"learning.accessExpiration.upgradeNow": "Jetzt aktualisieren",
|
||||
"learning.activeEnterprise.alert": "{changeActiveEnterprise}.",
|
||||
"learning.activeEnterprise.change.alert": "Jetzt Unternehmen wechseln",
|
||||
"learning.outline.alert.start.short": "Der Kurs beginnt {timeRemaining} um {courseStartTime}.",
|
||||
"learning.outline.alert.end.long": "Dieser Kurs endet am {courseEndDate} {timeRemaining}.",
|
||||
"learning.outline.alert.end.calendar": "Vergessen Sie nicht, eine Kalendererinnerung einzurichten!",
|
||||
"instructorToolbar.pageBanner.courseHasNotStarted": "Dieser Teilnehmer hat noch keinen Zugriff auf diesen Kurs. Der Kurs beginnt am {date}.",
|
||||
"learning.enrollment.alert": "Sie müssen im Kurs eingeschrieben sein, um den Inhalt sehen zu können.",
|
||||
"learning.staff.enrollment.alert": "Sie betrachten diesen Kurs als Dozent ohne eingeschrieben zu sein.",
|
||||
"learning.enrollment.enrollNow.Inline": "Jetzt einschreiben",
|
||||
"learning.enrollment.enrollNow.Sentence": "Jetzt einschreiben",
|
||||
"learning.enrollment.success": "Sie haben sich erfolgreich für diesen Kurs angemeldet!",
|
||||
"account-activation.alert.button": "Weiter zu {siteName}",
|
||||
"account-activation.alert.message": "Wir haben eine E-Mail an {boldEmail} mit einem Link zur Aktivierung Ihres Kontos gesendet. Ist die E-Mail nicht angekommen? Überprüfen Sie Ihren Spam-Ordner oder {sendEmailTag}.",
|
||||
"account-activation.resend.link": "E-Mail erneut senden",
|
||||
"learning.logistration.alert": "Um den Inhalt des Kurses sehen zu können, müssen Sie sich erst {sign_in_link} oder {register_link}.",
|
||||
"account-activation.alert.title": "Aktivieren Sie Ihr Konto um sich wieder anmelden zu können",
|
||||
"learn.sequence.entranceExamTextNotPassing": "Um auf Kursmaterialien zugreifen zu können, müssen Sie bei dieser Prüfung mindestens {entranceExamMinimumScorePct} % erreichen. Ihre aktuelle Punktzahl beträgt {entranceExamCurrentScore} %.",
|
||||
"learn.sequence.entranceExamTextPassed": "Ihre Punktzahl beträgt {entranceExamCurrentScore} %, damit haben Sie die Aufnahmeprüfung bestanden.",
|
||||
"learning.dates.badge.completed": "Abgeschlossen",
|
||||
"learning.dates.badge.dueNext": "Nächster Abgabetermin",
|
||||
"learning.dates.badge.pastDue": "Überfällig",
|
||||
"learning.dates.title": "Wichtige Daten",
|
||||
"learning.dates.badge.today": "Heute",
|
||||
"learning.dates.badge.unreleased": "Noch nicht veröffentlicht",
|
||||
"learning.dates.badge.verifiedOnly": "Nur verifiziert",
|
||||
"learning.goals.unsubscribe.contact": "kontaktieren Sie den Support",
|
||||
"learning.goals.unsubscribe.description": "Sie erhalten keine E-Mail-Erinnerungen mehr für {courseTitle}.",
|
||||
"learning.goals.unsubscribe.errorHeader": "Es ist ein Fehler aufgetreten",
|
||||
"learning.goals.unsubscribe.goToDashboard": "Zur Übersicht",
|
||||
"learning.goals.unsubscribe.header": "Sie haben sich von der E-Mail-Liste für die Kurserinnerungen abgemeldet",
|
||||
"learning.goals.unsubscribe.loading": "Abmelden…",
|
||||
"learning.goals.unsubscribe.errorDescription": "Wir konnten Sie nicht von Kurserinnerungs-E-Mails abmelden. Bitte versuchen Sie es später erneut oder {contactSupport} um Hilfe.",
|
||||
"learning.outline.alert.cert.earnedNotAvailable": "Dieser Kurs endet am {courseEndDateFormatted}. Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {certificateAvailableDate} verfügbar.",
|
||||
"cert.alert.earned.unavailable.header.v2": "Ihre Note und Ihr Zertifikat werden in Kürze verfügbar sein.",
|
||||
"cert.alert.earned.ready.header": "Herzliche Glückwünsche! Ihr Zertifikat ist fertig.",
|
||||
"cert.alert.notPassing.header": "Sie haben noch keinen Anspruch auf ein Zertifikat",
|
||||
"cert.alert.notPassing.button": "Noten ansehen",
|
||||
"learning.outline.alert.end.short": "Dieser Kurs endet in {timeRemaining} um {courseEndTime}.",
|
||||
"alert.enroll": "um auf den vollständigen Kurs zuzugreifen.",
|
||||
"learning.privateCourse.signInOrRegister": "{signIn} oder {register} und schreiben Sie sich dann für diesen Kurs ein.",
|
||||
"learning.outline.alert.scheduled-content.heading": "Weitere Inhalte folgen in Kürze!",
|
||||
"learning.outline.alert.scheduled-content.body": "Für diesen Kurs werden zu einem späteren Zeitpunkt weitere Inhalte veröffentlicht. Achten Sie auf E-Mail-Updates oder schauen Sie in diesem Kurs für Updates.",
|
||||
"learning.outline.alert.scheduled-content.button": "Kursplan ansehen",
|
||||
"learning.outline.dates.all": "Alle Kurstermine anzeigen",
|
||||
"learning.outline.goalButton.casual.text": "1 Tag pro Woche",
|
||||
"learning.outline.goalButton.screenReader.text": "Lässig",
|
||||
"learning.outline.certificateAlt": "Beispielzertifikat",
|
||||
"learning.outline.collapseAll": "Alles zusammenklappen",
|
||||
"learning.outline.completedAssignment": "Beendet",
|
||||
"learning.outline.completedSection": "Abgeschlossener Abschnitt",
|
||||
"learning.outline.dates": "Wichtige Daten",
|
||||
"learning.outline.editGoal": "Lernziel bearbeiten",
|
||||
"learning.outline.expandAll": "Alle erweitern",
|
||||
"learning.outline.goal": "Lernziel",
|
||||
"learning.outline.goalReminderDetail": "Wenn wir feststellen, dass Sie Ihr Lernziel nicht erreicht haben, senden wir Ihnen eine E-Mail-Erinnerung.",
|
||||
"learning.outline.goalUnsure": "Noch nicht sicher",
|
||||
"learning.outline.handouts": "Kursmaterialien",
|
||||
"learning.outline.incompleteAssignment": "Unvollständig",
|
||||
"learning.outline.incompleteSection": "Unvollständiger Abschnitt",
|
||||
"learning.outline.goalButton.intense.text": "5 Tage die Woche",
|
||||
"learning.outline.goalButton.intense.title": "Intensiv",
|
||||
"learning.outline.learnMore": "Mehr erfahren",
|
||||
"learning.outline.altText.openSection": "Öffnen",
|
||||
"learning.proctoringPanel.header": "Dieser Kurs enthält beaufsichtigte Prüfungen",
|
||||
"learning.outline.goalButton.regular.text": "3 Tage die Woche",
|
||||
"learning.outline.goalButton.regular.title": "Regulär",
|
||||
"learning.outline.resumeBlurb": "Machen Sie dort weiter, wo Sie aufgehört haben",
|
||||
"learning.outline.resume": "Kurs fortsetzen",
|
||||
"learning.outline.setGoal": "Legen Sie zunächst ein Lernziel fest, indem Sie unten die Option auswählen, die Ihren Lernplan am besten beschreibt.",
|
||||
"learning.outline.setGoalReminder": "Definieren Sie Ihre Lernzielerinnerungen",
|
||||
"learning.outline.goalButton.casual.title": "Definieren Sie einen Lernzielstil.",
|
||||
"learning.outline.setWeeklyGoal": "Setzen Sie sich ein wöchentliches Lernziel",
|
||||
"learning.outline.setWeeklyGoalDetail": "Das Setzen eines Lernziels motiviert Sie, den Kurs zu beenden. Sie können es jederzeit anpassen.",
|
||||
"learning.outline.start": "Kurs starten",
|
||||
"learning.outline.startBlurb": "Beginnen Sie noch heute Ihren Kurs",
|
||||
"learning.outline.tools": "Kurswerkzeuge",
|
||||
"learning.outline.upgradeButton": "Upgrade ({symbol}{price})",
|
||||
"learning.outline.upgradeTitle": "Erhalten Sie ein Zertifikat",
|
||||
"learning.outline.welcomeMessage": "Willkommensnachricht",
|
||||
"learning.outline.welcomeMessageShowMoreButton": "Mehr anzeigen",
|
||||
"learning.outline.welcomeMessageShowLessButton": "Weniger anzeigen",
|
||||
"learning.outline.goalWelcome": "Willkommen zu",
|
||||
"learning.proctoringPanel.status.notStarted": "Nicht begonnen",
|
||||
"learning.proctoringPanel.status.started": "Gestartet",
|
||||
"learning.proctoringPanel.status.submitted": "Abgesendet",
|
||||
"learning.proctoringPanel.status.verified": "Geprüft",
|
||||
"learning.proctoringPanel.status.rejected": "Zurückgewiesen",
|
||||
"learning.proctoringPanel.status.error": "Fehler",
|
||||
"learning.proctoringPanel.status.otherCourseApproved": "In einem anderen Studiengang genehmigt",
|
||||
"learning.proctoringPanel.status.expiringSoon": "Läuft bald ab",
|
||||
"learning.proctoringPanel.status.expired": "Abgelaufen",
|
||||
"learning.proctoringPanel.status": "Aktueller Onboarding-Status:",
|
||||
"learning.proctoringPanel.message.notStarted": "Sie haben Ihre Onboarding-Prüfung noch nicht begonnen.",
|
||||
"learning.proctoringPanel.message.started": "Sie haben Ihre Onboarding-Prüfung begonnen.",
|
||||
"learning.proctoringPanel.message.submitted": "Sie haben Ihre Onboarding-Prüfung eingereicht.",
|
||||
"learning.proctoringPanel.message.verified": "Ihre Onboarding-Prüfung wurde in diesem Kurs genehmigt.",
|
||||
"learning.proctoringPanel.message.rejected": "Ihre Onboarding-Prüfung wurde abgelehnt. Bitte versuchen Sie das Onboarding erneut.",
|
||||
"learning.proctoringPanel.message.error": "Während Ihrer Onboarding-Prüfung ist ein Fehler aufgetreten. Bitte versuchen Sie das Onboarding erneut.",
|
||||
"learning.proctoringPanel.message.otherCourseApproved": "Ihre Onboarding-Prüfung wurde in einem anderen Kurs genehmigt.",
|
||||
"learning.proctoringPanel.detail.otherCourseApproved": "Wenn sich Ihr Gerät geändert hat, empfehlen wir Ihnen, die Onboarding-Prüfung dieses Kurses zu absolvieren, um sicherzustellen, dass Ihre Einrichtung weiterhin die Anforderungen für die Aufsicht erfüllt.",
|
||||
"learning.proctoringPanel.message.expiringSoon": "Ihr Onboarding-Profil wurde genehmigt. Ihr Onboarding-Status läuft jedoch bald ab. Bitte führen Sie das Onboarding erneut durch, um sicherzustellen, dass Sie weiterhin beaufsichtigte Prüfungen ablegen können.",
|
||||
"learning.proctoringPanel.message.expired": "Ihr Onboarding-Status ist abgelaufen. Bitte schließen Sie das Onboarding erneut ab, um weiterhin beaufsichtigte Prüfungen ablegen zu können.",
|
||||
"learning.proctoringPanel.generalInfo": "Sie müssen den Onboarding-Prozess abschließen, bevor Sie eine beaufsichtigte Prüfung ablegen.",
|
||||
"learning.proctoringPanel.generalInfoSubmitted": "Ihr eingereichtes Profil wird überprüft.",
|
||||
"learning.proctoringPanel.generalTime": "Die Überprüfung des Onboarding-Profils kann mehr als 2 Werktage dauern.",
|
||||
"learning.proctoringPanel.onboardingButton": "Onboarding vervollständigt",
|
||||
"learning.proctoringPanel.onboardingPracticeButton": "Onboarding-Prüfung anzeigen",
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding-Öffnungen: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Lesen Sie die Anweisungen und Systemanforderungen",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding überfällig",
|
||||
"learning.outline.sequence-due-date-set": "{description} fällig {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "Um ein Zertifikat zu generieren, müssen Sie die ID-Verifizierung abschließen. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Präsentieren Sie Ihre Leistung noch heute auf LinkedIn oder in Ihrem Lebenslauf. Sie können Ihr Zertifikat jetzt herunterladen und jederzeit über Ihr Dashboard und Ihr Profil darauf zugreifen.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {endDate} verfügbar.",
|
||||
"progress.certificateStatus.notPassingHeader": "Zertifikatsstatus",
|
||||
"progress.certificateStatus.notPassingBody": "Um sich für ein Zertifikat zu qualifizieren, müssen Sie eine bestandene Note haben.",
|
||||
"progress.certificateStatus.inProgressHeader": "Weitere Inhalte folgen in Kürze!",
|
||||
"progress.certificateStatus.inProgressBody": "Es sieht so aus, als gäbe es in diesem Kurs weitere Inhalte, die in Zukunft veröffentlicht werden. Achten Sie auf E-Mail-Updates oder sehen Sie in Ihrem Kurs nach, wann diese Inhalte verfügbar sein werden.",
|
||||
"progress.certificateStatus.requestableHeader": "Zertifikatsstatus",
|
||||
"progress.certificateStatus.requestableBody": "Herzlichen Glückwunsch, Sie haben sich für ein Zertifikat qualifiziert! Um auf Ihr Zertifikat zuzugreifen, fordern Sie es unten an.",
|
||||
"progress.certificateStatus.requestableButton": "Zertifikat anfordern",
|
||||
"progress.certificateStatus.unverifiedHeader": "Zertifikatsstatus",
|
||||
"progress.certificateStatus.unverifiedButton": "ID verifizieren",
|
||||
"progress.certificateStatus.courseCelebration.verificationPending": "Ihre ID-Überprüfung steht noch aus und Ihr Zertifikat ist verfügbar, sobald es genehmigt wurde.",
|
||||
"progress.certificateStatus.downloadableHeader": "Ihr Zertifikat liegt vor!",
|
||||
"progress.certificateStatus.viewableButton": "Sehen Sie sich mein Zertifikat an",
|
||||
"progress.certificateStatus.notAvailableHeader": "Zertifikatsstatus",
|
||||
"progress.certificateBody.notAvailable.endDate": "Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {endDate} verfügbar.",
|
||||
"progress.certificateStatus.upgradeHeader": "Erhalten Sie ein Zertifikat",
|
||||
"progress.certificateStatus.upgradeBody": "Sie befinden sich in einem Audit Track und qualifizieren sich nicht für ein Zertifikat. Um auf ein Zertifikat hinzuarbeiten, upgraden Sie noch heute Ihren Kurs.",
|
||||
"progress.certificateStatus.upgradeButton": "Jetzt aktualisieren",
|
||||
"progress.certificateStatus.unverifiedHomeHeader.v2": "Überprüfen Sie Ihre Identität, um sich für ein Zertifikat zu qualifizieren.",
|
||||
"progress.certificateStatus.unverifiedHomeButton": "Meine ID bestätigen",
|
||||
"progress.certificateStatus.unverifiedHomeBody": "Um ein Zertifikat für diesen Kurs zu generieren, müssen Sie den ID-Verifizierungsprozess abschließen.",
|
||||
"progress.completion.donut.label": "abgeschlossen",
|
||||
"progress.completion.body": "Dies stellt dar, wie viel der Kursinhalte Sie abgeschlossen haben. Beachten Sie, dass einige Inhalte möglicherweise noch nicht veröffentlicht wurden.",
|
||||
"progress.completion.tooltip.locked": "Inhalte, die Sie abgeschlossen haben.",
|
||||
"progress.completion.header": "Kursabschluss",
|
||||
"progress.completion.tooltip": "Inhalte, auf die Sie Zugriff haben und die Sie noch nicht abgeschlossen haben.",
|
||||
"progress.completion.tooltip.complete": "Inhalt, der gesperrt und nur für diejenigen verfügbar ist, die ein Upgrade durchführen.",
|
||||
"progress.completion.donut.percentComplete": "Sie haben {percent} % des Inhalts dieses Kurses abgeschlossen.",
|
||||
"progress.completion.donut.percentIncomplete": "Sie haben {percent} % der Inhalte in diesem Kurs, auf die Sie Zugriff haben, noch nicht abgeschlossen.",
|
||||
"progress.completion.donut.percentLocked": "{percent} % der Inhalte in diesem Kurs sind gesperrt und nur für diejenigen verfügbar, die ein Upgrade durchführen.",
|
||||
"progress.creditInformation.creditNotEligible": "Sie können diesen Kurs nicht mehr anrechnen. Erfahren Sie mehr über {creditLink}.",
|
||||
"progress.creditInformation.creditEligible": "\nSie haben die Voraussetzungen für die Anrechnung in diesem Kurs erfüllt. Gehen Sie zu Ihrem\n {dashboardLink}, um Kursguthaben zu erwerben. Oder erfahren Sie mehr über {creditLink}.",
|
||||
"progress.creditInformation.creditPartialEligible": "Sie haben die Kreditvoraussetzungen noch nicht erfüllt. Erfahren Sie mehr über {creditLink}.",
|
||||
"progress.creditInformation.completed": "Beendet",
|
||||
"progress.creditInformation.courseCredit": "Kurskredit",
|
||||
"progress.creditInformation.minimumGrade": "Mindestnote für Kreditpunkte ({minGrade}%)",
|
||||
"progress.creditInformation.requirementsHeader": "Voraussetzungen für den Studienkredit",
|
||||
"progress.creditInformation.upcoming": "Demnächst",
|
||||
"progress.creditInformation.verificationFailed": "Überprüfung fehlgeschlagen",
|
||||
"progress.creditInformation.verificationSubmitted": "Bestätigung eingereicht",
|
||||
"progress.ungradedAlert": "Informationen zum Fortschritt bei nicht benoteten Aspekten des Kurses finden Sie in Ihrem {outlineLink}.",
|
||||
"progress.footnotes.droppableAssignments": "Die niedrigsten {numDroppable, plural, one{# {assignmentType} Punktzahl ist} other{# {assignmentType} Punktzahlen werden}} fallen gelassen.",
|
||||
"progress.assignmentType": "Auftragsart",
|
||||
"progress.footnotes.backToContent": "Zurück zum Inhalt",
|
||||
"progress.courseGrade.body": "Dies stellt Ihre gewichtete Note gegenüber der Note dar, die zum Bestehen dieses Kurses erforderlich ist.",
|
||||
"progress.courseGrade.gradeBar.altText": "Ihre aktuelle Note ist {currentGrade} %. Zum Bestehen dieses Kurses ist eine gewichtete Note von {passingGrade} % erforderlich.",
|
||||
"progress.courseGrade.footer.generic.passing": "Sie absolvieren derzeit diesen Kurs",
|
||||
"progress.courseGrade.footer.nonPassing": "Zum Bestehen dieses Kurses ist eine gewichtete Note von {passingGrade} % erforderlich",
|
||||
"progress.courseGrade.footer.passing": "Sie bestehen diesen Kurs derzeit mit der Note {letterGrade} ({minGrade}-{maxGrade}%)",
|
||||
"progress.courseGrade.preview.headerLocked": "gesperrte Funktion",
|
||||
"progress.courseGrade.preview.headerLimited": "eingeschränkte Funktion",
|
||||
"progress.courseGrade.preview.header.ariaHidden": "Vorschau auf a",
|
||||
"progress.courseGrade.preview.body.unlockCertificate": "Entsperren, um Noten anzuzeigen und auf ein Zertifikat hinzuarbeiten.",
|
||||
"progress.courseGrade.partialpreview.body.unlockCertificate": "Entsperren, um auf ein Zertifikat hinzuarbeiten.",
|
||||
"progress.courseGrade.preview.body.upgradeDeadlinePassed": "Die Frist für das Upgrade in diesem Kurs ist abgelaufen.",
|
||||
"progress.courseGrade.preview.button.upgrade": "Jetzt aktualisieren",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Notenbereiche für diesen Kurs:",
|
||||
"progress.courseOutline": "Kursübersicht",
|
||||
"progress.courseGrade.label.currentGrade": "Ihre aktuelle Note",
|
||||
"progress.detailedGrades": "Detaillierte Noten",
|
||||
"progress.detailedGrades.emptyTable": "Sie haben derzeit keine benoteten Problemergebnisse.",
|
||||
"progress.footnotes.title": "Notenzusammenfassung Fußnoten",
|
||||
"progress.gradeSummary.grade": "Note",
|
||||
"progress.courseGrade.grades": "Noten",
|
||||
"progress.courseGrade.gradesAndCredit": "Noten & Kredit",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Kurzinfo zum Notenbereich",
|
||||
"progress.gradeSummary": "Zusammenfassung der Noten",
|
||||
"progress.gradeSummary.limitedAccessExplanation": "Sie haben eingeschränkten Zugriff auf benotete Aufgaben im Rahmen des Audit-Tracks in diesem Kurs.",
|
||||
"progress.gradeSummary.tooltip.alt": "Kurzinfo zur Notenzusammenfassung",
|
||||
"progress.gradeSummary.tooltip.body": "Das Gewicht Ihrer Kursaufgabe wird von Ihrem Kursleiter festgelegt. Indem Sie Ihre Note mit der Gewichtung für diesen Aufgabentyp multiplizieren, wird Ihre gewichtete Note berechnet. Ihre gewichtete Note wird verwendet, um festzustellen, ob Sie den Kurs bestehen.",
|
||||
"progress.noAcessToAssignmentType": "Sie haben keinen Zugriff auf Aufgaben des Typs {assignmentType}",
|
||||
"progress.noAcessToSubsection": "Sie haben keinen Zugriff auf den Unterabschnitt {displayName}",
|
||||
"progress.courseGrade.label.passingGrade": "Klasse bestehen",
|
||||
"progress.detailedGrades.problemScore.label": "Problemwerte:",
|
||||
"progress.detailedGrades.problemScore.toggleButton": "Einzelne Problembewertungen für {subsectionTitle} umschalten",
|
||||
"progress.detailedGrades.overridden": "Die Note für den Abschnitt wurde überschrieben.",
|
||||
"progress.score": "Punkte",
|
||||
"progress.weight": "Gewichtung",
|
||||
"progress.weightedGrade": "Gewichtete Note",
|
||||
"progress.weightedGradeSummary": "Ihre aktuelle gewichtete Notenzusammenfassung",
|
||||
"progress.header": "Dein Fortschritt",
|
||||
"progress.header.targetUser": "Kursfortschritt für {username}",
|
||||
"progress.link.studio": "Grading in Studio anzeigen",
|
||||
"progress.relatedLinks.datesCard.description": "Eine Zeitplanansicht Ihrer Kurstermine und anstehenden Aufgaben.",
|
||||
"progress.relatedLinks.datesCard.link": "Daten",
|
||||
"progress.relatedLinks.outlineCard.description": "Ihre Kursinhalte aus der Vogelperspektive.",
|
||||
"progress.relatedLinks.outlineCard.link": "Kursübersicht",
|
||||
"progress.relatedLinks": "Ähnliche Links",
|
||||
"datesBanner.suggestedSchedule": "Wir haben einen vorgeschlagenen Zeitplan erstellt, um Ihnen zu helfen, auf dem richtigen Weg zu bleiben. Aber keine Sorge – es ist flexibel, sodass Sie in Ihrem eigenen Tempo lernen können.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.header": "Upgrade, um dies freizuschalten.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.body": "Sie sind Gasthörer für diesen Kurs, was bedeutet, dass Sie nicht an benoteten Aufgaben teilnehmen können. Um benotete Aufgaben im Rahmen dieses Kurses abzuschließen, können Sie noch heute upgraden.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.button": "Jetzt aktualisieren",
|
||||
"datesBanner.upgradeToResetBanner.body": "Sie können den Ablaufplan individuell anpassen und die überfälligen Aufgaben auf einen späteren Zeitpunkt verschieben. Keine Sorge—Ihr bisheriger Fortschritt geht dabei nicht verloren.",
|
||||
"datesBanner.upgradeToResetBanner.button": "Jetzt aktualisieren, um Abgabetermine zu verschieben.",
|
||||
"datesBanner.resetDatesBanner.header": "Es scheint, als wenn Sie ein paar wichtige Fristen unseres vorgeschlagenen Ablaufplans verpasst.",
|
||||
"datesBanner.resetDatesBanner.body": "Sie können den Ablaufplan individuell anpassen und die überfälligen Aufgaben auf einen späteren Zeitpunkt verschieben. Keine Sorge—Ihr bisheriger Fortschritt geht dabei nicht verloren.",
|
||||
"datesBanner.resetDatesBanner.button": "Verschiebe Abgabetermin",
|
||||
"learn.navigation.course.tabs.label": "Kursmaterial",
|
||||
"unit.bookmark.button.add.bookmark": "Diese Seite merken",
|
||||
"unit.bookmark.button.remove.bookmark": "Lesezeichen gesetzt",
|
||||
"learning.celebration.completed": "Sie haben gerade den ersten Abschnitt Ihres Studiums abgeschlossen.",
|
||||
"learning.celebration.congrats": "Glückwunsch!",
|
||||
"learning.celebration.earned": "Du hast es verdient!",
|
||||
"learning.celebration.emailSubject": "Ich bin auf dem Weg, {title} online mit {platform} abzuschließen!",
|
||||
"learning.celebration.forward": "Mach weiter",
|
||||
"learning.celebration.goalMet": "Du hast dein Ziel erreicht!",
|
||||
"learning.celebration.keepItUp": "Weiter so",
|
||||
"learning.celebration.share": "Nimm dir einen Moment Zeit, um zu feiern und deine Fortschritte zu teilen.",
|
||||
"learning.celebration.social": "Ich bin dabei, {title} online mit {platform} abzuschließen. Womit verbringst du deine Zeit mit Lernen?",
|
||||
"learning.celebration.goalCongrats": "Herzlichen Glückwunsch, Sie haben Ihr Lernziel von {nTimes} pro Woche erreicht.",
|
||||
"learning.celebration.setGoal": "Das Setzen eines Ziels kann Ihnen in Ihrem Kurs {strongText} helfen.",
|
||||
"calculator.instructions.button.label": "Rechner Anleitung",
|
||||
"calculator.instructions": "Ausführliche Informationen finden Sie im {expressions_link}.",
|
||||
"calculator.instructions.support.title": "Hilfe-Center",
|
||||
"calculator.instructions.useful.tips": "Nützliche Tipps:",
|
||||
"calculator.hint1": "Verwenden Sie Klammern (), um Ausdrücke deutlich zu machen. Sie können Klammern innerhalb anderer Klammern verwenden.",
|
||||
"calculator.hint2": "Bitte nutze keine Leerzeichen in deinem Ausdruck",
|
||||
"calculator.hint3": "Für Konstanten muss die Multiplikation explizit gekennzeichnet werden (Beispiel: 5*c)",
|
||||
"calculator.hint4": "Bei Einheitszusätzen muss die Einheit der Zahl direkt und ohne eingefuegte Leerzeichen folgen (Beispiel: 5m).",
|
||||
"calculator.hint5": "Für Funktionen muss der Name der Funktion gefolgt vom Funktionsargument in Klammern eingegeben werden.",
|
||||
"calculator.instruction.table.to.use.heading": "Zu nutzen",
|
||||
"calculator.instruction.table.type.heading": "Typ",
|
||||
"calculator.instruction.table.examples.heading": "Beispiele",
|
||||
"calculator.instruction.table.to.use.numbers": "Zahlen",
|
||||
"calculator.instruction.table.to.use.numbers.type1": "ganze Zahlen",
|
||||
"calculator.instruction.table.to.use.numbers.type2": "Brüche",
|
||||
"calculator.instruction.table.to.use.numbers.type3": "Dezimalstellen",
|
||||
"calculator.instruction.table.to.use.operators": "Operatoren",
|
||||
"calculator.instruction.table.to.use.operators.type1": "(addieren, subtrahieren, multiplizieren, dividieren)",
|
||||
"calculator.instruction.table.to.use.operators.type2": "(Potenz erheben)",
|
||||
"calculator.instruction.table.to.use.operators.type3": "(parallele Widerstände)",
|
||||
"calculator.instruction.table.to.use.constants": "Konstanten",
|
||||
"calculator.instruction.table.to.use.affixes": "Einheiten",
|
||||
"calculator.instruction.table.to.use.affixes.type": "Prozentzeichen (%)",
|
||||
"calculator.instruction.table.to.use.basic.functions": "Basisfunktionen",
|
||||
"calculator.instruction.table.to.use.trig.functions": "Trigonometrische Funktionen",
|
||||
"calculator.instruction.table.to.use.scientific.notation": "Wissenschaftliche Schreibweise",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type1": "{exponentSyntax} und der Exponent",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type2": "{notationSyntax}-Notation",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type3": "{notationSyntax} und der Exponent",
|
||||
"calculator.button.label": "Taschenrechner",
|
||||
"calculator.input.field.label": "Rechnereingabe",
|
||||
"calculator.submit.button.label": "Berechne",
|
||||
"calculator.result.field.label": "Rechenergebnis",
|
||||
"calculator.result.field.placeholder": "Ergebnis",
|
||||
"notes.button.show": "Notizen anzeigen",
|
||||
"notes.button.hide": "Notizen ausblenden",
|
||||
"courseExit.catalogSearchSuggestion": "Möchten Sie mehr erfahren? {searchOurCatalogLink}, um weitere Kurse und Programme zu finden, die Sie erkunden können.",
|
||||
"courseCelebration.certificateBody.available": "\nPräsentieren Sie Ihre Leistung noch heute auf LinkedIn oder in Ihrem Lebenslauf. \nSie können Ihr Zertifikat jetzt herunterladen und jederzeit von Ihrem \n{dashboardLink} und {profileLink} darauf zugreifen.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate.v2": "Dieser Kurs endet am {endDate}. Abschlussnoten und erworbene Zertifikate sind voraussichtlich nach {certAvailableDate} verfügbar.",
|
||||
"courseCelebration.certificateBody.unverified": "Um ein Zertifikat zu generieren, müssen Sie die ID-Verifizierung abschließen. {idVerificationSupportLink} jetzt.",
|
||||
"courseCelebration.certificateBody.upgradable": "Es ist noch nicht zu spät für ein Upgrade. Für {price} entsperren Sie den Zugriff auf alle benoteten Aufgaben in diesem Kurs. Nach Abschluss erhalten Sie ein verifiziertes Zertifikat, das ein wertvoller Nachweis ist, um Ihre Berufsaussichten zu verbessern und Ihre Karriere voranzutreiben oder Ihr Zertifikat in Schulbewerbungen hervorzuheben.",
|
||||
"courseCelebration.upgradeDiscountCodePrompt": "Mit dem Rabattcode {code} erhalten Sie an der Kasse {percent} % Rabatt!",
|
||||
"courseCelebration.recommendations.heading": "Bauen Sie Ihre Fähigkeiten mit diesen Kursen weiter aus!",
|
||||
"courseCelebration.recommendations.label": "Kurs",
|
||||
"courseCelebration.recommendations.formatting.list_join": "{style, select, punctuation {, } conjunction { {sp}und } other { }}",
|
||||
"courseCelebration.recommendations.browse_catalog": "Entdecken Sie weitere Kurse",
|
||||
"courseCelebration.recommendations.loading_recommendations": "Lade Empfehlungen",
|
||||
"courseCelebration.recommendations.card.schools.label": "Schulen und Partner",
|
||||
"courseCelebration.dashboardInfo": "Sie können auf diesen Kurs und seine Materialien auf Ihrem {dashboardLink} zugreifen.",
|
||||
"courseExit.programs.applyForCredit": "Kurs-Guthaben beantragen",
|
||||
"courseCelebration.certificateHeader.downloadable": "Ihr Zertifikat liegt vor!",
|
||||
"courseCelebration.certificateHeader.notAvailable": "Ihre Note und Ihr Zertifikatsstatus werden in Kürze verfügbar sein.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "Wenn Sie die Mindestnote erreicht haben, wird Ihr Zertifikat automatisch ausgestellt.",
|
||||
"courseCelebration.certificateHeader.unverified": "Sie müssen die Verifizierung abschließen, um Ihr Zertifikat zu erhalten.",
|
||||
"courseCelebration.certificateHeader.requestable": "Gratulation, Sie haben sich für ein Zertifikat qualifiziert!",
|
||||
"courseCelebration.certificateHeader.upgradable": "Führen Sie ein Upgrade durch, um ein verifiziertes Zertifikat zu erwerben",
|
||||
"courseCelebration.certificateImage": "Musterzertifikat",
|
||||
"courseCelebration.completedCourseHeader": "Sie haben Ihren Kurs abgeschlossen.",
|
||||
"courseCelebration.congratulationsHeader": "Glückwunsch!",
|
||||
"courseCelebration.congratulationsImage": "Vier Personen heben feierlich die Hände",
|
||||
"courseExit.courseInProgressDescription": "Es sieht so aus, als gäbe es in diesem Kurs weitere Inhalte, die in Zukunft veröffentlicht werden. Achten Sie auf E-Mail-Updates oder sehen Sie in Ihrem Kurs nach, wann diese Inhalte verfügbar sein werden.",
|
||||
"courseExit.courseInProgressHeader": "Weitere Inhalte folgen in Kürze!",
|
||||
"courseExit.dashboardLink": "Übersicht",
|
||||
"courseExit.endOfCourseDescription": "Leider haben Sie derzeit keinen Anspruch auf ein Zertifikat. Sie müssen eine Mindestnote erreichen, um sich für ein Zertifikat zu qualifizieren.",
|
||||
"courseExit.endOfCourseHeader": "Sie haben das Ende des Kurses erreicht!",
|
||||
"courseExit.endOfCourseTitle": "Ende des Kurses",
|
||||
"courseExit.idVerificationSupportLink": "Erfahren Sie mehr über die Identitätsprüfung",
|
||||
"courseCelebration.linkedinAddToProfileButton": "Zum LinkedIn-Profil hinzufügen",
|
||||
"courseExit.programs.microBachelors.learnMore": "Erfahren Sie mehr darüber, wie Ihr MicroBachelors-Zeugnis für eine Anrechnung beantragt werden kann.",
|
||||
"courseExit.programs.microMasters.learnMore": "Erfahren Sie mehr über das Verfahren zur Anwendung von MicroMasters-Zertifikaten auf Master-Abschlüsse.",
|
||||
"courseExit.programs.microMasters.mastersMessage": "Wenn Sie daran interessiert sind, Ihr MicroMasters-Zertifikat für ein Masterprogramm zu verwenden, können Sie noch heute loslegen!",
|
||||
"learn.sequence.navigation.complete.button": "Beenden Sie den Kurs",
|
||||
"courseExit.nextButton.endOfCourse": "Weiter (Ende natürlich)",
|
||||
"courseExit.profileLink": "Profil",
|
||||
"courseExit.programs.lastCourse": "Sie haben den letzten Kurs in {title} abgeschlossen!",
|
||||
"courseCelebration.requestCertificateBodyText": "Um auf Ihr Zertifikat zuzugreifen, fordern Sie es unten an.",
|
||||
"courseCelebration.requestCertificateButton": "Zertifikat anfordern",
|
||||
"courseExit.searchOurCatalogLink": "Suchen Sie in unserem Katalog",
|
||||
"courseCelebration.shareMessage": "Teilen Sie Ihren Erfolg in den sozialen Medien oder per E-Mail.",
|
||||
"courseExit.social.shareCompletionMessage": "Ich habe gerade {title} mit {platform} abgeschlossen!",
|
||||
"courseExit.upgradeButton": "Upgrade jetzt durchführen",
|
||||
"courseExit.upgradeLink": "Upgrade jetzt durchführen",
|
||||
"courseCelebration.verificationPending": "Ihre ID-Überprüfung steht noch aus und Ihr Zertifikat ist verfügbar, sobald es genehmigt wurde.",
|
||||
"courseExit.verifiedCertificateSupportLink": "Erfahren Sie mehr über verifizierte Zertifikate",
|
||||
"courseCelebration.verifyIdentityButton": "ID jetzt verifizieren",
|
||||
"courseCelebration.viewCertificateButton": "Sehen Sie sich mein Zertifikat an",
|
||||
"courseExit.viewCourseScheduleButton": "Kursplan ansehen",
|
||||
"courseExit.viewCoursesButton": "Sehen Sie sich meine Kurse an",
|
||||
"courseExit.viewGradesButton": "Noten ansehen",
|
||||
"courseExit.programCompletion.dashboardMessage": "Um Ihren Zertifikatsstatus anzuzeigen, sehen Sie im Abschnitt "Programme" Ihres {programLink} nach.",
|
||||
"courseExit.upgradeFootnote": "Der Zugriff auf diesen Kurs und seine Materialien ist auf Ihrem Dashboard bis {expirationDate} verfügbar. Um diese Zugriffsfrist zu verlängern, {upgradeLink}.",
|
||||
"learn.course.license.allRightsReserved.text": "Alle Rechte vorbehalten",
|
||||
"learn.course.license.creativeCommons.terms.preamble": "Von Creative Commons lizenzierte Inhalte mit den folgenden Bedingungen:",
|
||||
"learn.course.license.creativeCommons.terms.by": "Attribution",
|
||||
"learn.course.license.creativeCommons.terms.nc": "Nicht-kommerziell",
|
||||
"learn.course.license.creativeCommons.terms.nd": "No Derivatives",
|
||||
"learn.course.license.creativeCommons.terms.sa": "Share Alike",
|
||||
"learn.course.license.creativeCommons.terms.zero": "Keine Bedingungen",
|
||||
"learn.course.license.creativeCommons.text": "Some Rights Reserved",
|
||||
"learn.breadcrumb.navigation.course.home": "Kurs",
|
||||
"notification.tray.container": "Benachrichtigungsfach",
|
||||
"notification.open.button": "Benachrichtigungsleiste anzeigen",
|
||||
"notification.close.button": "Schließen Sie die Benachrichtigungsleiste",
|
||||
"responsive.close.notification": "Zurück zum Kurs",
|
||||
"notification.tray.title": "Benachrichtigungen",
|
||||
"notification.tray.no.message": "Sie haben derzeit keine neuen Benachrichtigungen.",
|
||||
"learn.contentLock.content.locked": "Inhalt nicht zugänglich",
|
||||
"learn.contentLock.complete.prerequisite": "Sie müssen die Voraussetzung erfüllen: ''{prereqSectionName}'', um auf diesen Inhalt zugreifen zu können.",
|
||||
"learn.contentLock.goToSection": "Gehen Sie zum Abschnitt „Voraussetzungen“.",
|
||||
"learn.hiddenAfterDue.gradeAvailable": "Wenn Sie diese Aufgabe abgeschlossen haben, ist Ihre Note im {progressPage} verfügbar.",
|
||||
"learn.hiddenAfterDue.header": "Die Einschreibungsfrist zu diesem Kurs ist abgelaufen.",
|
||||
"learn.hiddenAfterDue.description": "Diese Aufgabe ist nicht mehr verfügbar, da die Frist abgelaufen ist.",
|
||||
"learn.hiddenAfterDue.progressPage": "Fortschrittsseite",
|
||||
"learn.honorCode.content": "Ehrlichkeit und akademische Integrität sind wichtig für {siteName} und die Institutionen, die Kurse und Programme auf der {siteName}-Website anbieten. Indem ich unten auf „Ich stimme zu“ klicke, bestätige ich, dass ich die {link} für die {siteName}-Site gelesen und verstanden habe und mich daran halten werde.",
|
||||
"learn.honorCode.name": "Verhaltenskodex",
|
||||
"learn.honorCode.cancel": "Löschen",
|
||||
"learn.honorCode.agree": "Ich stimme zu",
|
||||
"learn.lockPaywall.title": "Benotete Aufgaben sind gesperrt",
|
||||
"learn.lockPaywall.content": "Führen Sie ein Upgrade durch, um Zugriff auf gesperrte Funktionen wie diese zu erhalten und Ihren Kurs optimal zu nutzen.",
|
||||
"learn.lockPaywall.content.pastExpiration": "Die Upgrade-Frist für diesen Kurs ist abgelaufen. Um ein Upgrade durchzuführen, melden Sie sich für die nächste verfügbare Sitzung an.",
|
||||
"learn.lockPaywall.courseDetails": "Kursdetails anzeigen",
|
||||
"learn.lockPaywall.example.alt": "Beispielzertifikat",
|
||||
"learn.lockPaywall.list.intro": "Wenn Sie ein Upgrade durchführen, können Sie:",
|
||||
"learn.header.h2.placeholder": "Überschriften der Ebene 2 können in Zukunft von Kursanbietern erstellt werden.",
|
||||
"learn.course.load.failure": "Beim Laden dieses Kurses ist ein Fehler aufgetreten.",
|
||||
"learn.loading.honor.codk": "Ehrencode-Nachrichten werden geladen...",
|
||||
"learn.loading.content.lock": "Nachrichten zu gesperrten Inhalten werden geladen...",
|
||||
"learn.loading.learning.sequence": "Lernsequenz wird geladen...",
|
||||
"learn.sequence.no.content": "Hier gibt es keinen Inhalt.",
|
||||
"learn.sequence.navigation.next.button": "Weiter",
|
||||
"learn.sequence.navigation.next.up.button": "Als nächstes: {title}",
|
||||
"learn.sequence.navigation.previous.button": "Zurück",
|
||||
"learn.course.sequence.navigation.mobile.menu": "{current} von {total}",
|
||||
"learn.sequence.share.button": "Teilen Sie diesen Inhalt",
|
||||
"learn.sequence.share.modal.title": "Titel",
|
||||
"learn.sequence.share.modal.body": "Kopieren Sie den Link unten, um diesen Inhalt zu teilen.",
|
||||
"learn.sequence.share.quote": "Hier ist ein lustiger Clip aus einem Kurs, den ich bei @edXonline belege.\n",
|
||||
"discussions.sidebar.title": "Diskussionen",
|
||||
"discussions.sidebar.open.button": "Diskussionsablage anzeigen",
|
||||
"learn.redirect.interstitial.message": "Umleitung...",
|
||||
"learn.loading.error": "Fehler: {error}",
|
||||
"learning.celebration.emailBody": "Womit verbringst du deine Zeit mit Lernen?",
|
||||
"learning.social.shareEmail": "Teilen Sie Ihren Fortschritt per E-Mail.",
|
||||
"learning.social.shareService": "Teilen Sie Ihren Fortschritt auf {service}.",
|
||||
"general.altText.close": "Schließen",
|
||||
"learning.logistration.register": "registrieren",
|
||||
"learning.logistration.login": "Anmelden",
|
||||
"general.signIn.sentenceCase": "Anmelden",
|
||||
"learn.course.tabs.navigation.overflow.menu": "Mehr...",
|
||||
"learning.offer.screenReaderPrices": "Originalpreis: {originalPrice}, Rabattpreis: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "Originalpreis: {originalPrice}",
|
||||
"learning.upgradeButton.buttonText": "Upgrade für {pricing}",
|
||||
"learning.upgradeNowButton.buttonText": "Upgrade jetzt für {pricing}",
|
||||
"learning.generic.upgradeNotification.expirationAccessLoss.progress": "einschließlich etwaiger Fortschritte",
|
||||
"learning.generic.upgradeNotification.expirationVerifiedCert.benefits": "Vorteile des Upgrades",
|
||||
"learning.generic.upgradeNotification.expirationAccessLoss": "Am {date} verlieren Sie jeglichen Zugriff auf diesen Kurs, {includingAnyProgress}.",
|
||||
"learning.generic.upgradeNotification.expirationVerifiedCert": "Durch das Upgrade Ihres Kurses können Sie ein verifiziertes Zertifikat erwerben und zahlreiche Funktionen freischalten. Mehr unter {benefitsOfUpgrading}.",
|
||||
"learning.generic.upgradeNotification.pastExpiration.content": "Die Upgrade-Frist für diesen Kurs ist abgelaufen. Um ein Upgrade durchzuführen, melden Sie sich für die nächste verfügbare Sitzung an.",
|
||||
"learning.generic.upgradeNotification.expirationDays": "{dayCount, number} {dayCount, plural, one {Tag} other {Tage}} übrig",
|
||||
"learning.generic.upgradeNotification.expirationHours": "{hourCount, number} {hourCount, plural, one {Stunde} other {Stunden}} übrig",
|
||||
"learning.generic.upgradeNotification.expirationMinutes": "Weniger als 1 Stunde übrig",
|
||||
"learning.generic.upgradeNotification.expiration": "Der Kurszugriff läuft am {date} ab",
|
||||
"learning.generic.upgradeNotification.pastExpiration.banner": "Upgrade-Frist am {date} abgelaufen",
|
||||
"learning.generic.upgradeNotification.firstTimeLearnerDiscount": "{percentage}% Rabatt für Erstlerner",
|
||||
"learning.generic.upgradeNotification.accessExpiration": "Führen Sie noch heute ein Upgrade für Ihren Kurs durch",
|
||||
"learning.generic.upgradeNotification.accessExpirationUrgent": "Ablauf des Kurszugriffs",
|
||||
"learning.generic.upgradeNotification.accessExpirationPast": "Ablauf des Kurszugriffs",
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "Streben Sie ein verifiziertes Zertifikat an",
|
||||
"learning.generic.upgradeNotification.code": "Verwenden Sie den Code {code} an der Kasse",
|
||||
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "verifiziertes Zertifikat",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Bekommen Sie ein {verifiedCertLink} für den Abschluss, zur Nutzung in Ihrem Lebenslauf",
|
||||
"learning.generic.upsell.unlockGradedBullet.gradedAssignments": "benotete Aufgaben",
|
||||
"learning.generic.upsell.unlockGradedBullet": "Entsperren Sie Ihren Zugang zu allen Kursaktivitäten, einschließlich {gradedAssignmentsInBoldText}",
|
||||
"learning.generic.upsell.fullAccessBullet.fullAccess": "Voller Zugriff",
|
||||
"learning.generic.upsell.fullAccessBullet": "{fullAccessInBoldText} auf Kursinhalte und -materialien, auch nach Kursende",
|
||||
"learning.generic.upsell.supportMissionBullet.mission": "Mission",
|
||||
"learning.generic.upsell.supportMissionBullet": "Unterstützen Sie unser {missionInBoldText} unter {siteName}",
|
||||
"masquerade-widget.userName.error.generic": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"masquerade-widget.userName.input.placeholder": "Benutzername oder E-Mail-Adresse",
|
||||
"masquerade-widget.userName.input.label": "Geben Sie vor, dieser Benutzer zu sein",
|
||||
"tours.abandonTour.launchTourCheckpoint.body": "Wissen Sie nicht weiter? Die jederzeit verfügbare Tour hilft Ihnen mit einfachen Tipps, das Beste aus Ihrem Lernerlebnis zu machen.",
|
||||
"tours.sequenceNavigationCheckpoint.body": "Die obere Leiste in Ihrem Kurs erlaubt Ihnen, zu verschiedenen Abschnitten zu springen. Sie zeigt Ihnen auch, was auf Sie zukommt.",
|
||||
"tours.existingUserTour.launchTourCheckpoint.body": "Wir haben kürzlich die Benutzerführung geändert. Brauchen Sie Hilfe, sich wieder zurecht zu finden? Machen Sie eine Tour, um mehr zu erfahren.",
|
||||
"tours.button.dismiss": "Tour Beenden",
|
||||
"tours.button.next": "Weiter",
|
||||
"tours.button.okay": "Okay",
|
||||
"tours.button.beginTour": "Rundgang beginnen",
|
||||
"tours.button.launchTour": "Rundgang starten",
|
||||
"tours.newUserModal.body": "Kommen Sie mit auf einen kurzen Rundgang durch {siteName}, damit Sie Ihren Kurs optimal nutzen können.",
|
||||
"tours.newUserModal.title.welcome": "Willkommen bei Ihrem",
|
||||
"tours.button.skipForNow": "Jetzt Nicht",
|
||||
"tours.datesCheckpoint.body": "Wichtige Termine können Ihnen helfen, am Ball zu bleiben.",
|
||||
"tours.datesCheckpoint.title": "Behalten Sie wichtige Termine im Auge",
|
||||
"tours.outlineCheckpoint.body": "Sie können Abschnitte des Kurses anhand der folgenden Gliederung erkunden.",
|
||||
"tours.outlineCheckpoint.title": "Machen Sie den Kurs!",
|
||||
"tours.tabNavigationCheckpoint.body": "Sie können über diese Registerkarten andere Kursinformationen erreichen, wie z. B. Ihren Fortschritt, Ihren Lehrplan, usw.",
|
||||
"tours.tabNavigationCheckpoint.title": "Zusätzliches Kursmaterial",
|
||||
"tours.upgradeCheckpoint.body": "Arbeiten Sie auf ein Zertifikat hin und erhalten Sie vollen Zugriff auf Kursmaterialien. Jetzt upgraden!",
|
||||
"tours.upgradeCheckpoint.title": "Schalten Sie Ihren Kurs frei",
|
||||
"tours.weeklyGoalsCheckpoint.body": "Wenn Sie sich ein Ziel setzen, ist es wahrscheinlicher, dass Sie Ihren Kurs abschließen.",
|
||||
"tours.weeklyGoalsCheckpoint.title": "Legen Sie ein Kursziel fest",
|
||||
"tours.newUserModal.title": "{welcome} im {siteName} Kurs!",
|
||||
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
|
||||
"learning.effortEstimation.activities": "{activityCount, plural, one {# Aktivität} other {# Aktivitäten}}",
|
||||
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# Mindest} other {# Minuten}}",
|
||||
"learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# Minute} other {# Protokoll}}",
|
||||
"learning.streakCelebration.congratulations": "Glückwunsch!",
|
||||
"learning.streakCelebration.body": "Weiter so, das läuft ja super!",
|
||||
"learning.streakCelebration.button": "Weiter so",
|
||||
"learning.streakCelebration.buttonSrOnly": "Dialogbox schließen und fortfahren",
|
||||
"learning.streakCelebration.buttonAA759": "Weiter im Kurs",
|
||||
"learning.streakCelebration.header": "-Tagesserie",
|
||||
"learning.streakCelebration.factoidABoldedSection": "haben eine 20-mal höhere Wahrscheinlichkeit, ihren Kurs zu bestehen",
|
||||
"learning.streakCelebration.factoidBBoldedSection": "absolvieren durchschnittlich 5x so viele Kursinhalte",
|
||||
"learning.streakCelebration.streakDiscountMessage": "Sie haben einen Rabatt von {percent} % freigeschaltet, wenn Sie diesen Kurs nur für eine begrenzte Zeit upgraden.",
|
||||
"learning.streakcelebration.factoida": "Benutzer, die {streak_length} Tage hintereinander {bolded_section} lernen, als diejenigen, die dies nicht tun.",
|
||||
"learning.streakcelebration.factoidb": "Benutzer, die {streak_length} Tage hintereinander {bolded_section} lernen, im Vergleich zu denen, die dies nicht tun.",
|
||||
"learning.streakCelebration.streakCelebrationCouponEndDateMessage": "Endet {date}.",
|
||||
"learning.loading.failure": "Beim Laden dieses Kurses ist ein Fehler aufgetreten.",
|
||||
"learning.loading": "Kursseite wird geladen…"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user