Compare commits
71 Commits
julianajlk
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72bce9e652 | ||
|
|
66482ab23a | ||
|
|
03e0f41692 | ||
|
|
8e1904a235 | ||
|
|
88485a0f77 | ||
|
|
361a099ed1 | ||
|
|
7f3757539a | ||
|
|
44f5132e2a | ||
|
|
53b19c9be3 | ||
|
|
abc374b60a | ||
|
|
af837fcac8 | ||
|
|
e328e3d597 | ||
|
|
559160213d | ||
|
|
878a4616f3 | ||
|
|
3028d79597 | ||
|
|
aa0de7663c | ||
|
|
acd91a1c31 | ||
|
|
b32817b3dd | ||
|
|
8b32e5892f | ||
|
|
76cf85f3d7 | ||
|
|
7d86c501a7 | ||
|
|
eeee32c100 | ||
|
|
95d88a054e | ||
|
|
550b15a16c | ||
|
|
715393d6ad | ||
|
|
19b4241020 | ||
|
|
09bd5bd748 | ||
|
|
89771cb56b | ||
|
|
3353ee2f9d | ||
|
|
7c1821382c | ||
|
|
b444d677b7 | ||
|
|
7178f28838 | ||
|
|
b07f22193c | ||
|
|
c6eba42120 | ||
|
|
7bb2266790 | ||
|
|
0a70f9b64e | ||
|
|
cfe4432c6b | ||
|
|
f7219b4f5d | ||
|
|
14a19b2794 | ||
|
|
8a9767cdd3 | ||
|
|
3cba1bbac4 | ||
|
|
9436770620 | ||
|
|
d03dd34009 | ||
|
|
9cdacde4dc | ||
|
|
a22ac3a776 | ||
|
|
7e19af44da | ||
|
|
57c3f3080e | ||
|
|
385635f5d1 | ||
|
|
a7f763cd2a | ||
|
|
c7c9c19771 | ||
|
|
1d3a779ef1 | ||
|
|
4f1a50ec24 | ||
|
|
72d18dc4f9 | ||
|
|
2197ec0c21 | ||
|
|
069ac9c234 | ||
|
|
3edf349969 | ||
|
|
a2516e9fcc | ||
|
|
554806e9ce | ||
|
|
ed13128fc4 | ||
|
|
373a2d88fc | ||
|
|
bcd54a4f4b | ||
|
|
c4cb0e5ac2 | ||
|
|
c77d518d04 | ||
|
|
703250c3d2 | ||
|
|
35ec314505 | ||
|
|
9fc7951576 | ||
|
|
4ed350c9c6 | ||
|
|
ebed27529c | ||
|
|
24ced5dc63 | ||
|
|
f004d0ab3c | ||
|
|
1bbcc6d052 |
1
.env
1
.env
@@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL=''
|
||||
CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
@@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
@@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
10
.github/workflows/validate.yml
vendored
10
.github/workflows/validate.yml
vendored
@@ -2,7 +2,7 @@ name: validate
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
@@ -11,14 +11,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [12, 14, 16]
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
1
.husky/_/.gitignore
vendored
Normal file
1
.husky/_/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
31
.husky/_/husky.sh
Normal file
31
.husky/_/husky.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
@@ -1,8 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.frontend-app-learning]
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-learning]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
type = KEYVALUEJSON
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -44,7 +44,7 @@ push_translations:
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -58,7 +58,6 @@ validate:
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run is-es5
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
33456
package-lock.json
generated
33456
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@@ -7,13 +7,11 @@
|
||||
"url": "git+https://github.com/edx/frontend-app-learning.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"prepare": "husky install",
|
||||
@@ -32,28 +30,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.2.0",
|
||||
"@edx/frontend-component-header": "2.4.3",
|
||||
"@edx/frontend-enterprise-utils": "1.1.1",
|
||||
"@edx/frontend-lib-special-exams": "1.15.5",
|
||||
"@edx/frontend-platform": "1.15.1",
|
||||
"@edx/paragon": "19.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@edx/frontend-component-footer": "10.2.2",
|
||||
"@edx/frontend-component-header": "2.4.6",
|
||||
"@edx/frontend-lib-special-exams": "1.16.3",
|
||||
"@edx/frontend-platform": "1.15.6",
|
||||
"@edx/paragon": "19.14.1",
|
||||
"@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.16",
|
||||
"@popperjs/core": "2.11.2",
|
||||
"@reduxjs/toolkit": "1.6.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.18",
|
||||
"@popperjs/core": "2.11.5",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.1",
|
||||
"core-js": "3.19.3",
|
||||
"core-js": "3.21.1",
|
||||
"js-cookie": "3.0.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.6",
|
||||
"react-redux": "7.2.8",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-share": "4.4.0",
|
||||
@@ -64,22 +61,18 @@
|
||||
"util": "0.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "9.1.2",
|
||||
"@edx/reactifex": "1.0.3",
|
||||
"@pact-foundation/pact": "9.17.2",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@edx/browserslist-config": "1.0.2",
|
||||
"@edx/frontend-build": "9.1.4",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "0.6.6",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"codecov": "3.8.3",
|
||||
"enzyme": "3.11.0",
|
||||
"es-check": "6.2.1",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.5.1",
|
||||
"jest-chain": "1.1.5",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
56
src/alerts/sequence-alerts/hooks.js
Normal file
56
src/alerts/sequence-alerts/hooks.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function useSequenceBannerTextAlert(sequenceId) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
// Show Alert that comes along with the sequence
|
||||
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const {
|
||||
entranceExamCurrentScore,
|
||||
entranceExamEnabled,
|
||||
entranceExamId,
|
||||
entranceExamMinimumScorePct,
|
||||
entranceExamPassed,
|
||||
} = course.entranceExamData || {};
|
||||
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
|
||||
let entranceExamText;
|
||||
|
||||
if (entranceExamPassed) {
|
||||
entranceExamText = intl.formatMessage(
|
||||
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
|
||||
);
|
||||
} else {
|
||||
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
|
||||
entranceExamCurrentScore: entranceExamCurrentScore * 100,
|
||||
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
|
||||
});
|
||||
}
|
||||
|
||||
useAlert(entranceExamAlertVisible, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: entranceExamText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };
|
||||
14
src/alerts/sequence-alerts/messages.js
Normal file
14
src/alerts/sequence-alerts/messages.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
entranceExamTextNotPassing: {
|
||||
id: 'learn.sequence.entranceExamTextNotPassing',
|
||||
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
|
||||
},
|
||||
entranceExamTextPassed: {
|
||||
id: 'learn.sequence.entranceExamTextPassed',
|
||||
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
@@ -9,6 +8,7 @@ Factory.define('courseHomeMetadata')
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
is_staff: false,
|
||||
can_load_courseware: true,
|
||||
celebrations: null,
|
||||
course_access: {
|
||||
@@ -19,7 +19,106 @@ Factory.define('courseHomeMetadata')
|
||||
user_fragment: null,
|
||||
user_message: null,
|
||||
},
|
||||
number: 'DemoX',
|
||||
original_user_is_staff: false,
|
||||
org: 'edX',
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
user_timezone: 'UTC',
|
||||
username: 'MockUser',
|
||||
});
|
||||
verified_mode: {
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
sku: '8CF08E5',
|
||||
price: 149,
|
||||
currency_symbol: '$',
|
||||
},
|
||||
})
|
||||
.attr(
|
||||
'tabs', ['id', 'host'], (id, host) => [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'discussion/forum/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course_wiki',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'progress',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'instructor',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Dates',
|
||||
priority: 5,
|
||||
slug: 'dates',
|
||||
type: 'dates',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'dates',
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ Factory.define('upgradeNotificationData')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.option('accessExpiration', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('upsellPageName', 'test')
|
||||
@@ -18,4 +17,9 @@ Factory.define('upgradeNotificationData')
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attrs({
|
||||
accessExpiration: {
|
||||
expiration_date: '1950-07-13T02:04:49.040006Z',
|
||||
},
|
||||
})
|
||||
.attr('timeOffsetMillis', 0);
|
||||
|
||||
@@ -76,9 +76,12 @@ Object {
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -391,9 +394,12 @@ Object {
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -586,9 +592,12 @@ Object {
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -605,7 +614,6 @@ Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
"visiblePercent": 1,
|
||||
},
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"creditCourseRequirements": null,
|
||||
|
||||
@@ -90,14 +90,21 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
||||
* @returns {Object} The normalized metadata
|
||||
*/
|
||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
||||
// If needed, we switch it to "outline" here for
|
||||
// use within the MFE to differentiate between course home and courseware.
|
||||
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
|
||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
@@ -182,11 +189,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
@@ -232,16 +239,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
|
||||
// assignmentPolicies have been filtered by what's visible to the learner.
|
||||
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
|
||||
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
|
||||
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
|
||||
) : camelCasedData.courseGrade.percent;
|
||||
|
||||
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
|
||||
>= Math.min(...Object.values(data.grading_policy.grade_range));
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('Course Home Service', () => {
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = await getCourseHomeCourseMetadata(courseId);
|
||||
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getCourseHomeCourseMetadata(courseId, 'outline'),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('DatesTab', () => {
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function LmsHtmlFragment({
|
||||
|
||||
const iframe = useRef(null);
|
||||
function resetIframeHeight() {
|
||||
if (iframe.current) {
|
||||
if (iframe?.current?.contentWindow?.document?.body) {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ function OutlineTab({ intl }) {
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
marketingUrl,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const eventProperties = {
|
||||
@@ -190,6 +194,7 @@ function OutlineTab({ intl }) {
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
|
||||
@@ -676,7 +676,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', async () => {
|
||||
const now = new Date();
|
||||
@@ -710,7 +710,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
|
||||
});
|
||||
it('renders non passing grade', async () => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -66,8 +66,8 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
alertProps.body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
|
||||
id="learning.outline.alert.cert.earnedNotAvailable"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
certStatusEarnedNotAvailableHeader: {
|
||||
id: 'cert.alert.earned.unavailable.header',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
id: 'cert.alert.earned.unavailable.header.v2',
|
||||
defaultMessage: 'Your grade and certificate status will be available soon.',
|
||||
description: 'Header alerting the user that their certificate will be available soon.',
|
||||
},
|
||||
certStatusDownloadableHeader: {
|
||||
|
||||
@@ -56,7 +56,7 @@ function StartOrResumeCourseCard({ intl }) {
|
||||
)}
|
||||
/>
|
||||
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
|
||||
<Card.Footer />
|
||||
<Card.Footer><></></Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -166,6 +168,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('0%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('0%');
|
||||
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -213,6 +217,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('80%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('80%');
|
||||
expect(await screen.findByText('You’re currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -442,9 +448,8 @@ describe('Progress Tab', () => {
|
||||
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders correct current grade tooltip when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
it('does not render subsections for which showGrades is false', async () => {
|
||||
// The second assignment has showGrades set to false, so it should not be shown.
|
||||
setTabData({
|
||||
section_scores: [
|
||||
{
|
||||
@@ -486,10 +491,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
// Although the learner's true grade is passing, we should expect this to reflect the grade that's
|
||||
// visible to them, which is non-passing
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
expect(screen.getByText('First subsection')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct title when credit information is available', async () => {
|
||||
@@ -661,52 +664,6 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct total weighted grade when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
setTabData({
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: false,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
});
|
||||
|
||||
it('renders override notice', async () => {
|
||||
setTabData({
|
||||
section_scores: [
|
||||
|
||||
@@ -157,7 +157,7 @@ function CertificateStatus({ intl }) {
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="This course ends on {endDate}. Final grades and certificates are
|
||||
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certAvailabilityDate}."
|
||||
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
|
||||
values={{ endDate, certAvailabilityDate }}
|
||||
|
||||
@@ -92,8 +92,8 @@ const messages = defineMessages({
|
||||
description: 'Button text which leaner needs to upgrade to get the certifcate',
|
||||
},
|
||||
unverifiedHomeHeader: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeHeader',
|
||||
defaultMessage: 'Verify your identity to earn a certificate!',
|
||||
id: 'progress.certificateStatus.unverifiedHomeHeader.v2',
|
||||
defaultMessage: 'Verify your identity to qualify for a certificate.',
|
||||
description: 'Header text when the learner needs to do verification to earn a certifcate ',
|
||||
},
|
||||
unverifiedHomeButton: {
|
||||
|
||||
@@ -19,11 +19,11 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
percent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
const currentGrade = Number((percent * 100).toFixed(0));
|
||||
|
||||
let currentGradeDirection = currentGrade < 50 ? '' : '-';
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ function GradeBar({ intl, passingGrade }) {
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
percent,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
const currentGrade = Number((percent * 100).toFixed(0));
|
||||
|
||||
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ function GradeSummaryTableFooter({ intl }) {
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
percent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
|
||||
const totalGrade = (visiblePercent * 100).toFixed(0);
|
||||
const totalGrade = (percent * 100).toFixed(0);
|
||||
|
||||
return (
|
||||
<DataTable.TableFooter className={`border-top border-primary ${bgColor}`}>
|
||||
|
||||
@@ -23,12 +23,10 @@ describe('Course Tabs Navigation', () => {
|
||||
};
|
||||
render(<CourseTabsNavigation {...mockData} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[0].title }))
|
||||
.toHaveAttribute('href', tabs[0].url)
|
||||
.toHaveClass('active');
|
||||
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveAttribute('href', tabs[0].url);
|
||||
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveClass('active');
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[1].title }))
|
||||
.toHaveAttribute('href', tabs[1].url)
|
||||
.not.toHaveClass('active');
|
||||
expect(screen.getByRole('link', { name: tabs[1].title })).toHaveAttribute('href', tabs[1].url);
|
||||
expect(screen.getByRole('link', { name: tabs[1].title })).not.toHaveClass('active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('CoursewareContainer', () => {
|
||||
sequenceBlocks: [defaultSequenceBlock],
|
||||
} = buildSimpleCourseBlocks(
|
||||
defaultCourseId,
|
||||
defaultCourseMetadata.name,
|
||||
defaultCourseHomeMetadata.title,
|
||||
{ unitBlocks: defaultUnitBlocks },
|
||||
);
|
||||
|
||||
@@ -147,6 +147,9 @@ describe('CoursewareContainer', () => {
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${unitBlock.id}`;
|
||||
axiosMock.onGet(sequenceMetadataUrl).reply(422, {});
|
||||
});
|
||||
|
||||
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
|
||||
axiosMock.onGet(discussionConfigUrl).reply(200, { provider: 'legacy' });
|
||||
}
|
||||
|
||||
async function loadContainer() {
|
||||
@@ -170,15 +173,16 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
describe('when receiving successful course data', () => {
|
||||
const courseMetadata = defaultCourseMetadata;
|
||||
const courseHomeMetadata = defaultCourseHomeMetadata;
|
||||
const courseId = defaultCourseId;
|
||||
|
||||
function assertLoadedHeader(container) {
|
||||
const courseHeader = container.querySelector('.learning-header');
|
||||
// Ensure the course number and org appear - this proves we loaded course metadata properly.
|
||||
expect(courseHeader).toHaveTextContent(courseMetadata.number);
|
||||
expect(courseHeader).toHaveTextContent(courseMetadata.org);
|
||||
expect(courseHeader).toHaveTextContent(courseHomeMetadata.number);
|
||||
expect(courseHeader).toHaveTextContent(courseHomeMetadata.org);
|
||||
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
|
||||
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
|
||||
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseHomeMetadata.title);
|
||||
}
|
||||
|
||||
function assertSequenceNavigation(container, expectedUnitCount = 3) {
|
||||
@@ -247,7 +251,7 @@ describe('CoursewareContainer', () => {
|
||||
const {
|
||||
courseBlocks, unitTree, sequenceTree, sectionTree,
|
||||
} = buildBinaryCourseBlocks(
|
||||
courseId, courseMetadata.name,
|
||||
courseId, courseHomeMetadata.title,
|
||||
);
|
||||
|
||||
function setUrl(urlSequenceId, urlUnitId = null) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -12,10 +12,10 @@ import Sequence from './sequence';
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import SidebarTriggers from './sidebar/SidebarTriggers';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
|
||||
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -31,6 +31,10 @@ function Course({
|
||||
windowWidth,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
celebrations,
|
||||
isStaff,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
|
||||
@@ -40,27 +44,19 @@ function Course({
|
||||
course,
|
||||
].filter(element => element != null).map(element => element.title);
|
||||
|
||||
const {
|
||||
celebrations,
|
||||
courseGoals,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad(
|
||||
courseId, sequenceId, celebrateFirstSection, dispatch, celebrations,
|
||||
));
|
||||
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false);
|
||||
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
|
||||
// the weekly goal celebration modal.
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const daysPerWeek = courseGoals?.selectedGoal?.daysPerWeek;
|
||||
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
// Responsive breakpoints for showing the notification button/tray
|
||||
const shouldDisplayNotificationTriggerInCourse = windowWidth >= breakpoints.small.minWidth;
|
||||
const shouldDisplayNotificationTrayOpenOnLoad = windowWidth > breakpoints.medium.minWidth;
|
||||
|
||||
// Course specific notification tray open/closed persistance by browser session
|
||||
@@ -73,65 +69,38 @@ function Course({
|
||||
}
|
||||
}
|
||||
|
||||
const [notificationTrayVisible, setNotificationTray] = verifiedMode
|
||||
&& shouldDisplayNotificationTrayOpenOnLoad && getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed' ? useState(true) : useState(false);
|
||||
|
||||
const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray;
|
||||
|
||||
const toggleNotificationTray = () => {
|
||||
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
|
||||
if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
|
||||
} else {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
|
||||
}
|
||||
};
|
||||
|
||||
if (!getLocalStorage(`notificationStatus.${courseId}`)) {
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'active'); // Show red dot on notificationTrigger until seen
|
||||
}
|
||||
|
||||
if (!getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)) {
|
||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'initialize');
|
||||
}
|
||||
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setupgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
const onNotificationSeen = () => {
|
||||
setNotificationStatus('inactive');
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'inactive');
|
||||
};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
|
||||
|
||||
useEffect(() => {
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
|
||||
courseId,
|
||||
sequenceId,
|
||||
celebrateFirstSection,
|
||||
dispatch,
|
||||
celebrations,
|
||||
));
|
||||
}, [sequenceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider courseId={courseId} unitId={unitId}>
|
||||
<Helmet>
|
||||
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
<div className="position-relative">
|
||||
<div className="position-relative d-flex align-items-start">
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
isStaff={course ? course.isStaff : null}
|
||||
isStaff={isStaff}
|
||||
unitId={unitId}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
{ shouldDisplayNotificationTriggerInCourse ? (
|
||||
<NotificationTrigger
|
||||
courseId={courseId}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
setNotificationStatus={setNotificationStatus}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
/>
|
||||
) : null}
|
||||
{shouldDisplayTriggers && (
|
||||
<SidebarTriggers />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertList topic="sequence" />
|
||||
@@ -142,14 +111,6 @@ function Course({
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
setNotificationStatus={setNotificationStatus}
|
||||
onNotificationSeen={onNotificationSeen}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
@@ -167,7 +128,7 @@ function Course({
|
||||
<ContentTools course={course} />
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{ MMP2P.meta.modalLock && <MMP2PBlockModal options={MMP2P} /> }
|
||||
</>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import {
|
||||
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
|
||||
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import Course from './Course';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import Course from './Course';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('./NotificationTray', () => () => <div data-testid="NotificationTray" />);
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
@@ -65,8 +64,8 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays first section celebration modal', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } });
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { firstSection: true } });
|
||||
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
@@ -85,8 +84,8 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays weekly goal celebration modal', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { weeklyGoal: true } });
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { weeklyGoal: true } });
|
||||
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
@@ -106,11 +105,10 @@ describe('Course', () => {
|
||||
render(<Course {...mockData} />);
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger).toHaveClass('trigger-active');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger).not.toHaveClass('trigger-active');
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
|
||||
});
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
@@ -118,10 +116,10 @@ describe('Course', () => {
|
||||
render(<Course {...mockData} />);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByTestId('NotificationTray')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(screen.queryByTestId('NotificationTray')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
@@ -222,4 +220,94 @@ describe('Course', () => {
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
describe('Sequence alerts display', () => {
|
||||
it('renders banner text alert', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block', { type: 'sequential', banner_text: 'Some random banner text to display.' },
|
||||
)];
|
||||
const sequenceMetadata = [Factory.build(
|
||||
'sequenceMetadata', { banner_text: sequenceBlocks[0].banner_text },
|
||||
{ courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] },
|
||||
)];
|
||||
|
||||
const testStore = await initializeTestStore({ courseMetadata, sequenceBlocks, sequenceMetadata });
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId: courseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with passing score', async () => {
|
||||
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
|
||||
const testCourseMetadata = Factory.build('courseMetadata', {
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 1.0,
|
||||
entrance_exam_enabled: true,
|
||||
entrance_exam_id: sectionId,
|
||||
entrance_exam_minimum_score_pct: 0.7,
|
||||
entrance_exam_passed: true,
|
||||
},
|
||||
});
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
const sectionBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
|
||||
});
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with non-passing score', async () => {
|
||||
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
|
||||
const testCourseMetadata = Factory.build('courseMetadata', {
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 0.3,
|
||||
entrance_exam_enabled: true,
|
||||
entrance_exam_id: sectionId,
|
||||
entrance_exam_minimum_score_pct: 0.7,
|
||||
entrance_exam_passed: false,
|
||||
},
|
||||
});
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
const sectionBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
|
||||
});
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 2.4rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.notification-dot {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.55rem;
|
||||
border-radius: 50% !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
breakpoints,
|
||||
Icon,
|
||||
IconButton,
|
||||
useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBackIos, Close } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
|
||||
function NotificationTray({
|
||||
intl, toggleNotificationTray, onNotificationSeen, upgradeNotificationCurrentState, setupgradeNotificationCurrentState,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
contentTypeGatingEnabled,
|
||||
offer,
|
||||
org,
|
||||
timeOffsetMillis,
|
||||
userTimezone,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
|
||||
// After three seconds, update notificationSeen (to hide red dot)
|
||||
useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []);
|
||||
|
||||
return (
|
||||
<section className={classNames('notification-tray-container ml-0 ml-lg-4', { 'no-notification': !verifiedMode && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.notificationTray)}>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div className="mobile-close-container" onClick={() => { toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="mobile-close">{intl.formatMessage(messages.responsiveCloseNotificationTray)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="notification-tray-title">{intl.formatMessage(messages.notificationTitle)}</span>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: (
|
||||
<div className="d-inline-flex close-btn">
|
||||
<IconButton src={Close} size="sm" iconAs={Icon} onClick={() => { toggleNotificationTray(); }} variant="primary" alt={intl.formatMessage(messages.closeNotificationTrigger)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="notification-tray-divider" />
|
||||
<div>{verifiedMode
|
||||
? (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
||||
/>
|
||||
) : <p className="notification-tray-content">{intl.formatMessage(messages.noNotificationsMessage)}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTray.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func,
|
||||
onNotificationSeen: PropTypes.func,
|
||||
upgradeNotificationCurrentState: PropTypes.string.isRequired,
|
||||
setupgradeNotificationCurrentState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
NotificationTray.defaultProps = {
|
||||
toggleNotificationTray: null,
|
||||
onNotificationSeen: null,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTray);
|
||||
@@ -1,67 +0,0 @@
|
||||
.notification-tray-container {
|
||||
border: 1px solid $light-400;
|
||||
border-radius: 4px;
|
||||
width: 31rem;
|
||||
vertical-align: top;
|
||||
height: 100%;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'lg')) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
}
|
||||
|
||||
.no-notification {
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.notification-tray-title {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 0 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
float: right;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.notification-tray-divider {
|
||||
height: 0.5rem;
|
||||
background: $gray-100;
|
||||
border-top: 1px solid $light-400;
|
||||
border-bottom: 1px solid $light-400;
|
||||
}
|
||||
|
||||
.notification-tray-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mobile-close-container {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid $light-400;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
svg {
|
||||
top: 0.4rem;
|
||||
left: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-close {
|
||||
font-weight: 500;
|
||||
margin-left: 1.2rem;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
|
||||
import { fetchCourse } from '../data';
|
||||
import {
|
||||
render, initializeMockApp, screen, fireEvent, waitFor,
|
||||
} from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
import NotificationTray from './NotificationTray';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('NotificationTray', () => {
|
||||
let mockData;
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultMetadata = Factory.build('courseMetadata');
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseMetadata', attributes, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
global.innerWidth = breakpoints.large.minWidth;
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
mockData = {
|
||||
toggleNotificationTray: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders notification tray and close tray button', async () => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
await fetchAndRender(<NotificationTray {...testData} />);
|
||||
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
const notificationCloseIconButton = screen.getByRole('button', { name: /Close notification tray/i });
|
||||
expect(notificationCloseIconButton).toBeInTheDocument();
|
||||
expect(notificationCloseIconButton).toHaveClass('btn-icon-primary');
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
|
||||
// should not render responsive "Back to course" to close the tray
|
||||
expect(screen.queryByText('Back to course')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(<NotificationTray />);
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
|
||||
expect(UpgradeNotification).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no notifications message if no verified mode', async () => {
|
||||
setMetadata({ verified_mode: null });
|
||||
await fetchAndRender(<NotificationTray />);
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification tray with full screen "Back to course" at responsive view', async () => {
|
||||
global.innerWidth = breakpoints.medium.maxWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
await fetchAndRender(<NotificationTray {...testData} />);
|
||||
|
||||
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
|
||||
await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(responsiveCloseButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
|
||||
|
||||
import NotificationIcon from './NotificationIcon';
|
||||
import messages from './messages';
|
||||
|
||||
function NotificationTrigger({
|
||||
courseId, intl, toggleNotificationTray, isNotificationTrayVisible, notificationStatus, setNotificationStatus,
|
||||
upgradeNotificationCurrentState,
|
||||
}) {
|
||||
/* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages
|
||||
The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available,
|
||||
compare with the last state they've seen, and if it's different then set dot back to red */
|
||||
function UpdateUpgradeNotificationLastSeen() {
|
||||
if (upgradeNotificationCurrentState) {
|
||||
if (getLocalStorage(`upgradeNotificationLastSeen.${courseId}`) !== upgradeNotificationCurrentState) {
|
||||
setNotificationStatus('active');
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'active');
|
||||
setLocalStorage(`upgradeNotificationLastSeen.${courseId}`, upgradeNotificationCurrentState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { UpdateUpgradeNotificationLastSeen(); });
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('notification-trigger-btn', { 'trigger-active': isNotificationTrayVisible() })}
|
||||
type="button"
|
||||
onClick={() => { toggleNotificationTray(); }}
|
||||
aria-label={intl.formatMessage(messages.openNotificationTrigger)}
|
||||
>
|
||||
<NotificationIcon status={notificationStatus} notificationColor="bg-danger-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTrigger.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func.isRequired,
|
||||
notificationStatus: PropTypes.string.isRequired,
|
||||
setNotificationStatus: PropTypes.func.isRequired,
|
||||
isNotificationTrayVisible: PropTypes.func.isRequired,
|
||||
upgradeNotificationCurrentState: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTrigger);
|
||||
@@ -1,24 +0,0 @@
|
||||
.notification-trigger-btn {
|
||||
border: 1px solid $light-400;
|
||||
background: none;
|
||||
margin-top: 1rem;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
border: none;
|
||||
margin: 0.3rem 1.25rem 0 0.25rem;
|
||||
top: 0.1rem;
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-bottom: 2px solid $primary-700;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ClapsMobile from './assets/claps_280x201.gif';
|
||||
import ClapsTablet from './assets/claps_456x328.gif';
|
||||
import messages from './messages';
|
||||
@@ -19,12 +20,13 @@ import { useModel } from '../../../generic/model-store';
|
||||
function CelebrationModal({
|
||||
courseId, intl, isOpen, onClose, ...rest
|
||||
}) {
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org, celebrations } = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
recordFirstSectionCelebration(org, courseId);
|
||||
recordFirstSectionCelebration(org, courseId, celebrations, dispatch);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useModel } from '../../../generic/model-store';
|
||||
function WeeklyGoalCelebrationModal({
|
||||
courseId, daysPerWeek, intl, isOpen, onClose, ...rest
|
||||
}) {
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
||||
@@ -15,9 +15,20 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId) {
|
||||
});
|
||||
}
|
||||
|
||||
function recordFirstSectionCelebration(org, courseId) {
|
||||
function recordFirstSectionCelebration(org, courseId, celebrations, dispatch) {
|
||||
// Tell the LMS
|
||||
postCelebrationComplete(courseId, { first_section: false });
|
||||
// Update our local copy of course data from LMS
|
||||
dispatch(updateModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
celebrations: {
|
||||
...celebrations,
|
||||
firstSection: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Tell our analytics
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
@@ -71,7 +82,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSectio
|
||||
|
||||
// Update our local copy of course data from LMS
|
||||
dispatch(updateModel({
|
||||
modelType: 'coursewareMeta',
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
celebrations: {
|
||||
|
||||
15
src/courseware/course/celebration/utils.test.jsx
Normal file
15
src/courseware/course/celebration/utils.test.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { recordFirstSectionCelebration } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('./data/api');
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(() => ({ administrator: 'admin' })),
|
||||
}));
|
||||
|
||||
describe('recordFirstSectionCelebration', () => {
|
||||
it('updates the local copy of the course data from the LMS', async () => {
|
||||
const dispatchMock = jest.fn();
|
||||
recordFirstSectionCelebration('org', 'courseId', 'celebration', dispatchMock);
|
||||
expect(dispatchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -49,12 +49,10 @@ describe('Notes Visibility', () => {
|
||||
render(<NotesVisibility {...mockData} />);
|
||||
|
||||
const button = screen.getByRole('switch', { name: 'Show Notes' });
|
||||
expect(button)
|
||||
.not.toBeChecked()
|
||||
.toHaveClass('text-success');
|
||||
expect(button.querySelector('svg'))
|
||||
.toHaveClass('fa-pencil-alt')
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
expect(button).not.toBeChecked();
|
||||
expect(button).toHaveClass('text-success');
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-pencil-alt');
|
||||
expect(button.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('shows notes', () => {
|
||||
@@ -63,12 +61,10 @@ describe('Notes Visibility', () => {
|
||||
render(<NotesVisibility {...testData} />);
|
||||
|
||||
const button = screen.getByRole('switch', { name: 'Hide Notes' });
|
||||
expect(button)
|
||||
.toBeChecked()
|
||||
.toHaveClass('text-secondary');
|
||||
expect(button.querySelector('svg'))
|
||||
.toHaveClass('fa-pencil-alt')
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
expect(button).toBeChecked();
|
||||
expect(button).toHaveClass('text-secondary');
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-pencil-alt');
|
||||
expect(button.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('handles click', async () => {
|
||||
@@ -84,9 +80,8 @@ describe('Notes Visibility', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Notes' }));
|
||||
await waitFor(() => expect(mockFn).toHaveBeenCalled());
|
||||
expect(mockFn)
|
||||
.toHaveBeenCalledTimes(1)
|
||||
.toHaveBeenCalledWith('tools.toggleNotes');
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith('tools.toggleNotes');
|
||||
|
||||
expect(axiosMock.history.put).toHaveLength(1);
|
||||
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { logClick } from './utils';
|
||||
|
||||
function CatalogSuggestion({ intl, variant }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const searchOurCatalogLink = (
|
||||
|
||||
@@ -46,14 +46,17 @@ function CourseCelebration({ intl }) {
|
||||
linkedinAddToProfileUrl,
|
||||
marketingUrl,
|
||||
offer,
|
||||
org,
|
||||
relatedPrograms,
|
||||
title,
|
||||
verifiedMode,
|
||||
verifyIdentityUrl,
|
||||
verificationStatus,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
org,
|
||||
verifiedMode,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
@@ -127,9 +130,9 @@ function CourseCelebration({ intl }) {
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="This course ended on {endDate} and final grades and certificates are scheduled to be
|
||||
available after {certAvailableDate}."
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate.v2"
|
||||
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certAvailableDate}."
|
||||
values={{ endDate, certAvailableDate }}
|
||||
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
|
||||
/>
|
||||
|
||||
@@ -24,10 +24,11 @@ function CourseExit({ intl }) {
|
||||
enrollmentMode,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
isMasquerading,
|
||||
userHasPassingGrade,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const { isMasquerading } = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
|
||||
@@ -24,32 +24,38 @@ jest.mock('@edx/frontend-platform/analytics');
|
||||
describe('Course Exit Pages', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
const defaultMetadata = Factory.build('courseMetadata', {
|
||||
const coursewareMetadata = Factory.build('courseMetadata', {
|
||||
user_has_passing_grade: true,
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
});
|
||||
const { courseBlocks: defaultCourseBlocks } = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name);
|
||||
const courseId = coursewareMetadata.id;
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const { courseBlocks: defaultCourseBlocks } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title);
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
let coursewareMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
||||
coursewareMetadataUrl = appendBrowserTimezoneToUrl(coursewareMetadataUrl);
|
||||
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
|
||||
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
|
||||
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
|
||||
function setMetadata(attributes) {
|
||||
const courseMetadata = { ...defaultMetadata, ...attributes };
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
function setMetadata(coursewareAttributes, courseHomeAttributes = {}) {
|
||||
const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes };
|
||||
axiosMock.onGet(coursewareMetadataUrl).reply(200, extendedCourseMetadata);
|
||||
const extendedCourseHomeMetadata = { ...courseHomeMetadata, ...courseHomeAttributes };
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, extendedCourseHomeMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(coursewareMetadataUrl).reply(200, coursewareMetadata);
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||||
Factory.build('courseRecommendations', {}, { numRecs: 2 }));
|
||||
axiosMock.onGet(enrollmentsUrl).reply(200, []);
|
||||
@@ -94,7 +100,7 @@ describe('Course Exit Pages', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +136,7 @@ describe('Course Exit Pages', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays request certificate link', async () => {
|
||||
@@ -160,7 +166,7 @@ describe('Course Exit Pages', () => {
|
||||
it('Displays verify identity link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${defaultMetadata.id}/`,
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${courseId}/`,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'Verify ID now' })).toBeInTheDocument();
|
||||
@@ -171,7 +177,7 @@ describe('Course Exit Pages', () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verification_status: 'pending',
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${defaultMetadata.id}/`,
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${courseId}/`,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
|
||||
@@ -182,6 +188,8 @@ describe('Course Exit Pages', () => {
|
||||
it('Displays upgrade link when available', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
},
|
||||
{
|
||||
verified_mode: {
|
||||
access_expiration_date: '9999-08-06T12:00:00Z',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
@@ -202,6 +210,8 @@ describe('Course Exit Pages', () => {
|
||||
it('Displays nothing if audit only', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
},
|
||||
{
|
||||
verified_mode: null,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
@@ -367,7 +377,7 @@ describe('Course Exit Pages', () => {
|
||||
describe('Course in progress experience', () => {
|
||||
it('Displays link to dates tab', async () => {
|
||||
setMetadata({ user_has_passing_grade: false });
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name,
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title,
|
||||
{ hasScheduledContent: true });
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
|
||||
@@ -394,7 +404,7 @@ describe('Course Exit Pages', () => {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toMatch(url);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${defaultMetadata.id}","subscribed_to_reminders":false}`);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","subscribed_to_reminders":false}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ import { logClick, logVisit } from './utils';
|
||||
|
||||
function CourseInProgress({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org, tabs, title } = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
org,
|
||||
tabs,
|
||||
title,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
// Get dates tab link for 'view course schedule' button
|
||||
|
||||
@@ -16,7 +16,11 @@ import { logClick, logVisit } from './utils';
|
||||
|
||||
function CourseNonPassing({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org, tabs, title } = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
org,
|
||||
tabs,
|
||||
title,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
// Get progress tab link for 'view grades' button
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import truncate from 'truncate-html';
|
||||
import { useModel } from '../../../generic/model-store/hooks';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import fetchCourseRecommendations from './data/thunks';
|
||||
import { FAILED, LOADED, LOADING } from './data/slice';
|
||||
import CatalogSuggestion from './CatalogSuggestion';
|
||||
@@ -106,8 +106,8 @@ function CourseCard({
|
||||
<Card.ImageCap src={image.src} />
|
||||
<Card.Header title={truncate(title, 70, { reserveLastWord: -1 })} subtitle={subtitle} size="sm" />
|
||||
{/* Section is needed for internal vertical spacing to work out. If you can remove, be my guest */}
|
||||
<Card.Section />
|
||||
<Card.Footer textElement={intl.formatMessage(messages.recommendationsCourseFooter)} />
|
||||
<Card.Section> <></> </Card.Section>
|
||||
<Card.Footer textElement={intl.formatMessage(messages.recommendationsCourseFooter)}><></></Card.Footer>
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
@@ -133,7 +133,8 @@ const IntlCard = injectIntl(CourseCard);
|
||||
|
||||
function CourseRecommendations({ intl, variant }) {
|
||||
const { courseId, recommendationsStatus } = useSelector(state => ({ ...state.recommendations, ...state.courseware }));
|
||||
const { org, number, recommendations } = useModel('coursewareMeta', courseId);
|
||||
const { recommendations } = useModel('coursewareMeta', courseId);
|
||||
const { org, number } = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const courseKey = `${org}+${number}`;
|
||||
@@ -143,15 +144,17 @@ function CourseRecommendations({ intl, variant }) {
|
||||
dispatch(fetchCourseRecommendations(courseKey, courseId));
|
||||
}, [dispatch]);
|
||||
|
||||
const recommendationsLength = recommendations ? recommendations.length : 0;
|
||||
|
||||
if (recommendationsStatus && recommendationsStatus !== LOADING) {
|
||||
sendTrackEvent('edx.ui.lms.course_exit.recommendations.viewed', {
|
||||
course_key: courseKey,
|
||||
recommendations_status: recommendationsStatus,
|
||||
recommendations_length: recommendations ? recommendations.length : 0,
|
||||
recommendations_length: recommendationsLength,
|
||||
});
|
||||
}
|
||||
|
||||
if (recommendationsStatus === FAILED || (recommendationsStatus === LOADED && recommendations.length < 2)) {
|
||||
if (recommendationsStatus === FAILED || (recommendationsStatus === LOADED && recommendationsLength < 2)) {
|
||||
return (<CatalogSuggestion variant={variant} />);
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ function CourseRecommendations({ intl, variant }) {
|
||||
<div className="mb-2 mt-3">
|
||||
<DataTable
|
||||
isPaginated
|
||||
itemCount={recommendations.length}
|
||||
itemCount={recommendationsLength}
|
||||
data={recommendationData}
|
||||
columns={[{ Header: 'Title', accessor: 'title' }]}
|
||||
initialState={{
|
||||
|
||||
@@ -18,7 +18,7 @@ import { logClick } from './utils';
|
||||
|
||||
function DashboardFootnote({ intl, variant }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const dashboardLink = (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function UpgradeFootnote({ deadline, href, intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const upgradeLink = (
|
||||
|
||||
@@ -13,7 +13,7 @@ const messages = defineMessages({
|
||||
},
|
||||
certificateHeaderNotAvailable: {
|
||||
id: 'courseCelebration.certificateHeader.notAvailable',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
defaultMessage: 'Your grade and certificate status will be available soon.',
|
||||
description: 'Header displayed when course certificate is not yet available to be viewed',
|
||||
},
|
||||
certificateNotAvailableBodyAccessCert: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useContext, useState,
|
||||
useEffect, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
@@ -15,16 +15,16 @@ import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../../alerts/sequence-alerts/hooks';
|
||||
|
||||
import CourseLicense from '../course-license';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import SidebarTriggers from '../sidebar/SidebarTriggers';
|
||||
import messages from './messages';
|
||||
import HiddenAfterDue from './hidden-after-due';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import SequenceContent from './SequenceContent';
|
||||
import NotificationTray from '../NotificationTray';
|
||||
import NotificationTrigger from '../NotificationTrigger';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { isMobile } from '../../../experiments/mm-p2p/utils';
|
||||
@@ -38,17 +38,13 @@ function Sequence({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
intl,
|
||||
toggleNotificationTray,
|
||||
notificationTrayVisible,
|
||||
isNotificationTrayVisible,
|
||||
notificationStatus,
|
||||
setNotificationStatus,
|
||||
onNotificationSeen,
|
||||
upgradeNotificationCurrentState,
|
||||
setupgradeNotificationCurrentState,
|
||||
mmp2p,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
isStaff,
|
||||
originalUserIsStaff,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
@@ -97,26 +93,20 @@ function Sequence({
|
||||
sendTrackingLogEvent(eventName, payload);
|
||||
};
|
||||
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
useSequenceBannerTextAlert(sequenceId);
|
||||
useSequenceEntranceExamAlert(courseId, sequenceId, intl);
|
||||
|
||||
useEffect(() => {
|
||||
let id = null;
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequence.bannerText) {
|
||||
id = add({
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
function receiveMessage(event) {
|
||||
const { type } = event.data;
|
||||
if (type === 'entranceExam.passed') {
|
||||
// I know this seems (is) intense. It is implemented this way since we need to refetch the underlying
|
||||
// course blocks that were originally hidden because the Entrance Exam was not passed.
|
||||
global.location.reload();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (id) {
|
||||
remove(id);
|
||||
}
|
||||
};
|
||||
}, [sequenceStatus, sequence]);
|
||||
global.addEventListener('message', receiveMessage);
|
||||
}, []);
|
||||
|
||||
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
|
||||
const handleUnitLoaded = () => {
|
||||
@@ -138,11 +128,11 @@ function Sequence({
|
||||
const loading = sequenceStatus === 'loading' || (sequenceStatus === 'failed' && sequenceMightBeUnit);
|
||||
if (loading) {
|
||||
if (!sequenceId) {
|
||||
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
|
||||
return (<div> {intl.formatMessage(messages.noContent)} </div>);
|
||||
}
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -159,8 +149,8 @@ function Sequence({
|
||||
};
|
||||
|
||||
const defaultContent = (
|
||||
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
||||
<div className={classNames('sequence', { 'position-relative': shouldDisplayNotificationTriggerInSequence })} style={{ width: '100%' }}>
|
||||
<div className="sequence-container d-inline-flex flex-row">
|
||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
@@ -183,17 +173,7 @@ function Sequence({
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
|
||||
{shouldDisplayNotificationTriggerInSequence ? (
|
||||
<NotificationTrigger
|
||||
courseId={courseId}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
setNotificationStatus={setNotificationStatus}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
/>
|
||||
) : null}
|
||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
@@ -202,7 +182,6 @@ function Sequence({
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
@@ -223,16 +202,7 @@ function Sequence({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{notificationTrayVisible ? (
|
||||
<NotificationTray
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
onNotificationSeen={onNotificationSeen}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
||||
/>
|
||||
) : null }
|
||||
<Sidebar />
|
||||
|
||||
{/** [MM-P2P] Experiment */}
|
||||
{(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && (
|
||||
@@ -249,9 +219,8 @@ function Sequence({
|
||||
<SequenceExamWrapper
|
||||
sequence={sequence}
|
||||
courseId={courseId}
|
||||
isStaff={course.isStaff}
|
||||
originalUserIsStaff={course.originalUserIsStaff}
|
||||
isIntegritySignatureEnabled={course.isIntegritySignatureEnabled}
|
||||
isStaff={isStaff}
|
||||
originalUserIsStaff={originalUserIsStaff}
|
||||
canAccessProctoredExams={course.canAccessProctoredExams}
|
||||
>
|
||||
{defaultContent}
|
||||
@@ -264,7 +233,7 @@ function Sequence({
|
||||
// sequence status 'failed' and any other unexpected sequence status.
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
{intl.formatMessage(messages.loadFailure)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -277,14 +246,6 @@ Sequence.propTypes = {
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func,
|
||||
notificationTrayVisible: PropTypes.bool,
|
||||
isNotificationTrayVisible: PropTypes.func,
|
||||
notificationStatus: PropTypes.string.isRequired,
|
||||
setNotificationStatus: PropTypes.func.isRequired,
|
||||
onNotificationSeen: PropTypes.func,
|
||||
upgradeNotificationCurrentState: PropTypes.string.isRequired,
|
||||
setupgradeNotificationCurrentState: PropTypes.func.isRequired,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
@@ -303,11 +264,6 @@ Sequence.propTypes = {
|
||||
Sequence.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
toggleNotificationTray: null,
|
||||
notificationTrayVisible: null,
|
||||
isNotificationTrayVisible: null,
|
||||
onNotificationSeen: null,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
flyover: { isVisible: false },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { breakpoints } from '@edx/paragon';
|
||||
import {
|
||||
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore,
|
||||
} from '../../../setupTest';
|
||||
import SidebarContext from '../sidebar/SidebarContext';
|
||||
import Sequence from './Sequence';
|
||||
import { fetchSequenceFailure } from '../../data/slice';
|
||||
|
||||
@@ -386,22 +387,25 @@ describe('Sequence', () => {
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
const testData = {
|
||||
...mockData,
|
||||
notificationTrayVisible: true,
|
||||
};
|
||||
render(<Sequence {...testData} />);
|
||||
render(
|
||||
<SidebarContext.Provider
|
||||
value={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }}
|
||||
>
|
||||
<Sequence {...mockData} />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click on notification tray close button', async () => {
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
notificationTrayVisible: true,
|
||||
};
|
||||
render(<Sequence {...testData} />);
|
||||
render(
|
||||
<SidebarContext.Provider
|
||||
value={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }}
|
||||
>
|
||||
<Sequence {...mockData} />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -16,7 +16,6 @@ function SequenceContent({
|
||||
sequenceId,
|
||||
unitId,
|
||||
unitLoadedHandler,
|
||||
notificationTrayVisible,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
@@ -32,7 +31,7 @@ function SequenceContent({
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -50,7 +49,7 @@ function SequenceContent({
|
||||
if (!unitId || !unit) {
|
||||
return (
|
||||
<div>
|
||||
{intl.formatMessage(messages['learn.sequence.no.content'])}
|
||||
{intl.formatMessage(messages.noContent)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +61,6 @@ function SequenceContent({
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={unitLoadedHandler}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
@@ -75,7 +73,6 @@ SequenceContent.propTypes = {
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
unitLoadedHandler: PropTypes.func.isRequired,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import React, {
|
||||
Suspense,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
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 messages from './messages';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
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 { fetchCourse } from '../../data';
|
||||
/** [MM-P2P] Experiment */
|
||||
import { MMP2PLockPaywall } from '../../../experiments/mm-p2p';
|
||||
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 messages from './messages';
|
||||
|
||||
const HonorCode = React.lazy(() => import('./honor-code'));
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
@@ -85,7 +81,6 @@ function Unit({
|
||||
onLoaded,
|
||||
id,
|
||||
intl,
|
||||
notificationTrayVisible,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
@@ -101,6 +96,7 @@ function Unit({
|
||||
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);
|
||||
@@ -121,46 +117,48 @@ function Unit({
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
// We use this ref so that we can hold a reference to the currently active event listener.
|
||||
const messageEventListenerRef = useRef(null);
|
||||
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'));
|
||||
function receiveMessage(event) {
|
||||
const { type, payload } = event.data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (event.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, event.data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}
|
||||
// If we currently have an event listener, remove it.
|
||||
if (messageEventListenerRef.current !== null) {
|
||||
global.removeEventListener('message', messageEventListenerRef.current);
|
||||
messageEventListenerRef.current = null;
|
||||
}
|
||||
// Now add our new receiveMessage handler as the event listener.
|
||||
global.addEventListener('message', receiveMessage);
|
||||
// And then save it to our ref for next time.
|
||||
messageEventListenerRef.current = receiveMessage;
|
||||
// When the component finally unmounts, use the ref to remove the correct handler.
|
||||
return () => global.removeEventListener('message', messageEventListenerRef.current);
|
||||
}, [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['learn.header.h2.placeholder'])}</h2>
|
||||
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
@@ -170,11 +168,11 @@ function Unit({
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} notificationTrayVisible={notificationTrayVisible} />
|
||||
<LockPaywall courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
@@ -185,7 +183,7 @@ function Unit({
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.honor.code'])}
|
||||
srMessage={intl.formatMessage(messages.loadingHonorCode)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -195,7 +193,7 @@ function Unit({
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && !showError && (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||
/>
|
||||
)}
|
||||
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && showError && (
|
||||
@@ -266,7 +264,6 @@ Unit.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
|
||||
@@ -131,6 +131,21 @@ describe('Unit', () => {
|
||||
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' };
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useModel } from '../../../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function HiddenAfterDue({ courseId, intl }) {
|
||||
const { tabs } = useModel('coursewareMeta', courseId);
|
||||
const { tabs } = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const progressTab = tabs.find(tab => tab.slug === 'progress');
|
||||
const progressLink = progressTab && progressTab.url && (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, breakpoints, useWindowSize } from '@edx/paragon';
|
||||
import {
|
||||
Alert, Hyperlink, breakpoints, useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import SidebarContext from '../../sidebar/SidebarContext';
|
||||
import messages from './messages';
|
||||
import certificateLocked from '../../../../generic/assets/edX_locked_certificate.png';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
@@ -19,15 +22,19 @@ import {
|
||||
function LockPaywall({
|
||||
intl,
|
||||
courseId,
|
||||
notificationTrayVisible,
|
||||
}) {
|
||||
const { notificationTrayVisible } = useContext(SidebarContext);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
accessExpiration,
|
||||
marketingUrl,
|
||||
offer,
|
||||
org,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
const {
|
||||
org, verifiedMode,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
// the following variables are set and used for resposive layout to work with
|
||||
// whether the NotificationTray is open or not and if there's an offer with longer text
|
||||
const shouldDisplayBulletPointsBelowCertificate = useWindowSize().width <= breakpoints.large.minWidth;
|
||||
@@ -39,6 +46,9 @@ function LockPaywall({
|
||||
&& !notificationTrayVisible;
|
||||
const shouldWrapTextOnButton = useWindowSize().width > breakpoints.extraSmall.minWidth;
|
||||
|
||||
const accessExpirationDate = accessExpiration ? new Date(accessExpiration.expirationDate) : null;
|
||||
const pastExpirationDeadline = accessExpiration ? new Date(Date.now()) > accessExpirationDate : false;
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +68,16 @@ function LockPaywall({
|
||||
});
|
||||
};
|
||||
|
||||
const logClickPastExpiration = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.gated_content.past_expiration.link_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: 'gated_content',
|
||||
linkName: 'course_details',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert variant="light" aria-live="off" icon={Locked} className="lock-paywall-container">
|
||||
<div className="row">
|
||||
@@ -65,12 +85,18 @@ function LockPaywall({
|
||||
<h4 aria-level="3">
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
|
||||
</h4>
|
||||
{pastExpirationDeadline ? (
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content.pastExpiration'])}
|
||||
<Hyperlink destination={marketingUrl} onClick={logClickPastExpiration} target="_blank">{intl.formatMessage(messages['learn.lockPaywall.courseDetails'])}</Hyperlink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content'])}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content'])}
|
||||
</div>
|
||||
|
||||
<div className={classNames('d-flex flex-row', { 'flex-wrap': notificationTrayVisible || shouldDisplayBulletPointsBelowCertificate })}>
|
||||
<div className={classNames('d-inline-flex flex-row', { 'flex-wrap': notificationTrayVisible || shouldDisplayBulletPointsBelowCertificate })}>
|
||||
<div style={{ float: 'left' }} className="mr-3 mb-2">
|
||||
<img
|
||||
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
|
||||
@@ -94,20 +120,24 @@ function LockPaywall({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
classNames('d-md-flex align-items-md-center text-right', {
|
||||
'col-md-5 mx-md-0': notificationTrayVisible, 'col-md-4 mx-md-3 justify-content-center': !notificationTrayVisible && !shouldDisplayGatedContentTwoColumnsHalf, 'col-md-11 justify-content-end': shouldDisplayGatedContentOneColumn && !shouldDisplayGatedContentTwoColumns, 'col-md-6 justify-content-center': shouldDisplayGatedContentTwoColumnsHalf,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
style={{ whiteSpace: shouldWrapTextOnButton ? 'nowrap' : null }}
|
||||
/>
|
||||
</div>
|
||||
{pastExpirationDeadline
|
||||
? null
|
||||
: (
|
||||
<div
|
||||
className={
|
||||
classNames('d-md-flex align-items-md-center text-right', {
|
||||
'col-md-5 mx-md-0': notificationTrayVisible, 'col-md-4 mx-md-3 justify-content-center': !notificationTrayVisible && !shouldDisplayGatedContentTwoColumnsHalf, 'col-md-11 justify-content-end': shouldDisplayGatedContentOneColumn && !shouldDisplayGatedContentTwoColumns, 'col-md-6 justify-content-center': shouldDisplayGatedContentTwoColumnsHalf,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
style={{ whiteSpace: shouldWrapTextOnButton ? 'nowrap' : null }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
@@ -115,6 +145,5 @@ function LockPaywall({
|
||||
LockPaywall.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
};
|
||||
export default injectIntl(LockPaywall);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.alert-content.lock-paywall-container {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lock-paywall-container svg {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Lock Paywall', () => {
|
||||
currencySymbol,
|
||||
price,
|
||||
upgradeUrl,
|
||||
} = store.getState().models.coursewareMeta[mockData.courseId].verifiedMode;
|
||||
} = store.getState().models.courseHomeMeta[mockData.courseId].verifiedMode;
|
||||
render(<LockPaywall {...mockData} />);
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: `Upgrade for ${currencySymbol}${price}` });
|
||||
@@ -56,7 +56,7 @@ describe('Lock Paywall', () => {
|
||||
const {
|
||||
currencySymbol,
|
||||
price,
|
||||
} = store.getState().models.coursewareMeta[mockData.courseId].verifiedMode;
|
||||
} = store.getState().models.courseHomeMeta[mockData.courseId].verifiedMode;
|
||||
render(<LockPaywall {...mockData} />);
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: `Upgrade for ${currencySymbol}${price}` });
|
||||
@@ -74,10 +74,48 @@ describe('Lock Paywall', () => {
|
||||
});
|
||||
|
||||
it('does not display anything if course does not have verified mode', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { verified_mode: null });
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
const { container } = render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { verified_mode: null });
|
||||
const testStore = await initializeTestStore({ courseHomeMetadata, excludeFetchSequence: true }, false);
|
||||
const { container } = render(<LockPaywall {...mockData} courseId={courseHomeMetadata.id} />, { store: testStore });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('displays past expiration message if expiration date has expired', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '1995-02-22T05:00:00Z',
|
||||
},
|
||||
marketing_url: 'https://example.com/course-details',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
expect(screen.getByText('The upgrade deadline for this course passed. To upgrade, enroll in the next available session.')).toBeInTheDocument();
|
||||
expect(screen.getByText('View Course Details'))
|
||||
.toHaveAttribute('href', 'https://example.com/course-details');
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of past expiration course details link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '1995-02-22T05:00:00Z',
|
||||
},
|
||||
marketing_url: 'https://example.com/course-details',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
const courseDetailsLink = await screen.getByText('View Course Details');
|
||||
fireEvent.click(courseDetailsLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.gated_content.past_expiration.link_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: mockData.courseId,
|
||||
linkCategory: 'gated_content',
|
||||
linkName: 'course_details',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Upgrade to gain access to locked features like this one and get the most out of your course.',
|
||||
description: 'Message shown to indicate that a piece of content is unavailable to audit track users.',
|
||||
},
|
||||
'learn.lockPaywall.content.pastExpiration': {
|
||||
id: 'learn.lockPaywall.content.pastExpiration',
|
||||
defaultMessage: 'The upgrade deadline for this course passed. To upgrade, enroll in the next available session. ',
|
||||
description: 'Message shown to indicate that a piece of content is unavailable to audit track users in a course where the expiration deadline has passed.',
|
||||
},
|
||||
'learn.lockPaywall.courseDetails': {
|
||||
id: 'learn.lockPaywall.courseDetails',
|
||||
defaultMessage: 'View Course Details',
|
||||
description: 'Link to the course details page for this course with a past expiration date.',
|
||||
},
|
||||
'learn.lockPaywall.example.alt': {
|
||||
id: 'learn.lockPaywall.example.alt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.content.lock': {
|
||||
id: 'learn.loading.content.lock',
|
||||
defaultMessage: 'Loading locked content messaging...',
|
||||
description: 'Message shown when an interface about locked content is being loaded',
|
||||
headerPlaceholder: {
|
||||
id: 'learn.header.h2.placeholder',
|
||||
defaultMessage: 'Level 2 headings may be created by course providers in the future.',
|
||||
description: 'Message spoken by a screenreader indicating that the h2 tag is a placeholder.',
|
||||
},
|
||||
'learn.loading.honor.code': {
|
||||
id: 'learn.loading.honor.codk',
|
||||
defaultMessage: 'Loading honor code messaging...',
|
||||
description: 'Message shown when an interface about the honor code is being loaded',
|
||||
},
|
||||
'learn.loading.learning.sequence': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
'learn.course.load.failure': {
|
||||
loadFailure: {
|
||||
id: 'learn.course.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
'learn.sequence.no.content': {
|
||||
loadingHonorCode: {
|
||||
id: 'learn.loading.honor.codk',
|
||||
defaultMessage: 'Loading honor code messaging...',
|
||||
description: 'Message shown when an interface about the honor code is being loaded',
|
||||
},
|
||||
loadingLockedContent: {
|
||||
id: 'learn.loading.content.lock',
|
||||
defaultMessage: 'Loading locked content messaging...',
|
||||
description: 'Message shown when an interface about locked content is being loaded',
|
||||
},
|
||||
loadingSequence: {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
noContent: {
|
||||
id: 'learn.sequence.no.content',
|
||||
defaultMessage: 'There is no content here.',
|
||||
description: 'Message shown when there is no content to show a user inside a learning sequence.',
|
||||
},
|
||||
'learn.header.h2.placeholder': {
|
||||
id: 'learn.header.h2.placeholder',
|
||||
defaultMessage: 'Level 2 headings may be created by course providers in the future.',
|
||||
description: 'Message spoken by a screenreader indicating that the h2 tag is a placeholder.',
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -69,8 +69,13 @@ UnitButton.defaultProps = {
|
||||
showCompletion: true,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
...state.models.units[props.unitId],
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
if (props.unitId) {
|
||||
return {
|
||||
...state.models.units[props.unitId],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(UnitButton);
|
||||
|
||||
18
src/courseware/course/sidebar/Sidebar.jsx
Normal file
18
src/courseware/course/sidebar/Sidebar.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React, { useContext } from 'react';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
function Sidebar() {
|
||||
const {
|
||||
currentSidebar,
|
||||
} = useContext(SidebarContext);
|
||||
if (!currentSidebar) {
|
||||
return null;
|
||||
}
|
||||
const CurrentSidebar = SIDEBARS[currentSidebar].Sidebar;
|
||||
return (
|
||||
<CurrentSidebar />
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
5
src/courseware/course/sidebar/SidebarContext.js
Normal file
5
src/courseware/course/sidebar/SidebarContext.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
const SidebarContext = React.createContext({});
|
||||
|
||||
export default SidebarContext;
|
||||
72
src/courseware/course/sidebar/SidebarContextProvider.jsx
Normal file
72
src/courseware/course/sidebar/SidebarContextProvider.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useState } 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';
|
||||
|
||||
export default function SidebarProvider({
|
||||
courseId,
|
||||
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 initialSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
|
||||
? SIDEBARS.NOTIFICATIONS.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);
|
||||
}
|
||||
}, [initialSidebar, verifiedMode]);
|
||||
|
||||
const onNotificationSeen = () => {
|
||||
setNotificationStatus('inactive');
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'inactive');
|
||||
};
|
||||
|
||||
const toggleSidebar = (sidebarId) => {
|
||||
// Switch to new sidebar or hide the current sidebar
|
||||
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{
|
||||
toggleSidebar,
|
||||
onNotificationSeen,
|
||||
setNotificationStatus,
|
||||
currentSidebar,
|
||||
notificationStatus,
|
||||
upgradeNotificationCurrentState,
|
||||
setUpgradeNotificationCurrentState,
|
||||
shouldDisplaySidebarOpen,
|
||||
shouldDisplayFullScreen,
|
||||
courseId,
|
||||
unitId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
SidebarProvider.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
SidebarProvider.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
32
src/courseware/course/sidebar/SidebarTriggers.jsx
Normal file
32
src/courseware/course/sidebar/SidebarTriggers.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext } from 'react';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
|
||||
function SidebarTriggers() {
|
||||
const {
|
||||
toggleSidebar,
|
||||
currentSidebar,
|
||||
} = useContext(SidebarContext);
|
||||
return (
|
||||
<div className="d-flex ml-auto">
|
||||
{SIDEBAR_ORDER.map((sidebarId) => {
|
||||
const { Trigger } = SIDEBARS[sidebarId];
|
||||
const isActive = sidebarId === currentSidebar;
|
||||
return (
|
||||
<div
|
||||
className={classNames('mt-3', { 'border-primary-700': isActive })}
|
||||
style={{ borderBottom: isActive ? '2px solid' : null }}
|
||||
key={sidebarId}
|
||||
>
|
||||
<Trigger onClick={() => toggleSidebar(sidebarId)} key={sidebarId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SidebarTriggers.propTypes = {};
|
||||
|
||||
export default SidebarTriggers;
|
||||
102
src/courseware/course/sidebar/common/SidebarBase.jsx
Normal file
102
src/courseware/course/sidebar/common/SidebarBase.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { ArrowBackIos, Close } from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { useEventListener } from '../../../../generic/hooks';
|
||||
import messages from '../../messages';
|
||||
import SidebarContext from '../SidebarContext';
|
||||
|
||||
function SidebarBase({
|
||||
intl,
|
||||
title,
|
||||
ariaLabel,
|
||||
sidebarId,
|
||||
className,
|
||||
children,
|
||||
showTitleBar,
|
||||
width,
|
||||
}) {
|
||||
const {
|
||||
toggleSidebar,
|
||||
shouldDisplayFullScreen,
|
||||
currentSidebar,
|
||||
} = useContext(SidebarContext);
|
||||
|
||||
const receiveMessage = useCallback(({ data }) => {
|
||||
const { type } = data;
|
||||
if (type === 'learning.events.sidebar.close') {
|
||||
toggleSidebar(null);
|
||||
}
|
||||
}, [sidebarId, toggleSidebar]);
|
||||
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
return currentSidebar === sidebarId && (
|
||||
<section
|
||||
className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top', {
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
}, className)}
|
||||
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div
|
||||
className="pt-2 pb-2.5 border-bottom border-light-400 d-flex align-items-center ml-2"
|
||||
onClick={() => toggleSidebar(null)}
|
||||
onKeyDown={() => toggleSidebar(null)}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}
|
||||
>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="font-weight-bold m-2 d-inline-block">
|
||||
{intl.formatMessage(messages.responsiveCloseNotificationTray)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{showTitleBar && (
|
||||
<>
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="p-2.5 d-inline-block">{title}</span>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: (
|
||||
<div className="d-inline-flex mr-2 mt-1.5 ml-auto">
|
||||
<IconButton
|
||||
src={Close}
|
||||
size="sm"
|
||||
iconAs={Icon}
|
||||
onClick={() => toggleSidebar(null)}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(messages.closeNotificationTrigger)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1 bg-gray-100 border-top border-bottom border-light-400" />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
SidebarBase.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
ariaLabel: PropTypes.string.isRequired,
|
||||
sidebarId: PropTypes.string.isRequired,
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.element.isRequired,
|
||||
showTitleBar: PropTypes.bool,
|
||||
width: PropTypes.string,
|
||||
};
|
||||
|
||||
SidebarBase.defaultProps = {
|
||||
width: '31rem',
|
||||
showTitleBar: true,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarBase);
|
||||
30
src/courseware/course/sidebar/common/TriggerBase.jsx
Normal file
30
src/courseware/course/sidebar/common/TriggerBase.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
function SidebarTriggerBase({
|
||||
onClick,
|
||||
ariaLabel,
|
||||
children,
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="border border-light-400 bg-transparent align-items-center align-content-center d-flex"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="icon-container d-flex position-relative align-items-center">
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
SidebarTriggerBase.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
ariaLabel: PropTypes.string.isRequired,
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarTriggerBase);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import React, { useContext } from 'react';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import SidebarBase from '../../common/SidebarBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import { ID } from './DiscussionsTrigger';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
ensureConfig(['DISCUSSIONS_MFE_BASE_URL']);
|
||||
|
||||
function DiscussionsSidebar({ intl }) {
|
||||
const {
|
||||
unitId,
|
||||
courseId,
|
||||
} = useContext(SidebarContext);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
if (!topic) {
|
||||
return null;
|
||||
}
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/topics/${topic.id}`;
|
||||
return (
|
||||
<SidebarBase
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
ariaLabel={intl.formatMessage(messages.discussionsTitle)}
|
||||
sidebarId={ID}
|
||||
width="50rem"
|
||||
showTitleBar={false}
|
||||
>
|
||||
<iframe
|
||||
src={`${discussionsUrl}?inContext`}
|
||||
className="d-flex w-100 border-0"
|
||||
// Need to set minHeight so there is enough space for the add post UI
|
||||
// TODO: Use postMessage API to dynamically update iframe size.
|
||||
style={{ minHeight: '60rem' }}
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
/>
|
||||
</SidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionsSidebar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
DiscussionsSidebar.Trigger = DiscussionsSidebar;
|
||||
DiscussionsSidebar.ID = ID;
|
||||
|
||||
export default injectIntl(DiscussionsSidebar);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
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 { useDispatch } from 'react-redux';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
||||
import SidebarTriggerBase from '../../common/TriggerBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import messages from './messages';
|
||||
|
||||
ensureConfig(['DISCUSSIONS_MFE_BASE_URL']);
|
||||
export const ID = 'DISCUSSIONS';
|
||||
|
||||
function DiscussionsTrigger({
|
||||
intl,
|
||||
onClick,
|
||||
}) {
|
||||
const {
|
||||
unitId,
|
||||
courseId,
|
||||
} = useContext(SidebarContext);
|
||||
const dispatch = useDispatch();
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch the topic data if the MFE is configured.
|
||||
if (baseUrl) {
|
||||
dispatch(getCourseDiscussionTopics(courseId));
|
||||
}
|
||||
}, [courseId, baseUrl]);
|
||||
if (!topic.id) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SidebarTriggerBase onClick={onClick} ariaLabel={intl.formatMessage(messages.openDiscussionsTrigger)}>
|
||||
<Icon src={QuestionAnswer} className="m-0 m-auto" />
|
||||
</SidebarTriggerBase>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionsTrigger.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DiscussionsTrigger);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent, initializeMockApp, initializeTestStore, render, screen,
|
||||
} from '../../../../../setupTest';
|
||||
import { buildTopicsFromUnits } from '../../../../data/__factories__/discussionTopics.factory';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import DiscussionsTrigger from './DiscussionsTrigger';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
describe('Discussions Trigger', () => {
|
||||
let axiosMock;
|
||||
let mockData;
|
||||
let courseId;
|
||||
let unitId;
|
||||
|
||||
beforeEach(async () => {
|
||||
const store = await initializeTestStore({
|
||||
excludeFetchCourse: false,
|
||||
excludeFetchSequence: false,
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const state = store.getState();
|
||||
courseId = state.courseware.courseId;
|
||||
[unitId] = Object.keys(state.models.units);
|
||||
|
||||
mockData = {
|
||||
courseId,
|
||||
unitId,
|
||||
};
|
||||
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(
|
||||
200,
|
||||
{
|
||||
provider: 'openedx',
|
||||
},
|
||||
);
|
||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
|
||||
.reply(200, buildTopicsFromUnits(state.models.units));
|
||||
});
|
||||
|
||||
function renderWithProvider(testData = {}, onClick = () => null) {
|
||||
const { container } = render(
|
||||
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
|
||||
<DiscussionsTrigger onClick={onClick} />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('shows up and handles onClick even if unit has discussion associated with it', async () => {
|
||||
const clickTrigger = jest.fn();
|
||||
renderWithProvider({}, clickTrigger);
|
||||
|
||||
const notificationTrigger = await screen.findByRole('button', { name: /Show discussions tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(clickTrigger).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('doesn\'t show up if unit has no discussion associated with it', async () => {
|
||||
const clickTrigger = jest.fn();
|
||||
renderWithProvider({ unitId: 'has-no-discussion' }, clickTrigger);
|
||||
|
||||
expect(await screen.queryByRole('button', { name: /Show discussions tray/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Sidebar } from './DiscussionsSidebar';
|
||||
export { default as Trigger, ID } from './DiscussionsTrigger';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
discussionsTitle: {
|
||||
id: 'discussions.sidebar.title',
|
||||
defaultMessage: 'Discussions',
|
||||
description: 'Title text for a forum where users are able to discuss course topics',
|
||||
},
|
||||
openDiscussionsTrigger: {
|
||||
id: 'discussions.sidebar.open.button',
|
||||
defaultMessage: 'Show discussions tray',
|
||||
description: 'Alt text for a button that opens the discussions feature',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
20
src/courseware/course/sidebar/sidebars/index.js
Normal file
20
src/courseware/course/sidebar/sidebars/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as notifications from './notifications';
|
||||
import * as discusssions from './discussions';
|
||||
|
||||
export const SIDEBARS = {
|
||||
[notifications.ID]: {
|
||||
ID: notifications.ID,
|
||||
Sidebar: notifications.Sidebar,
|
||||
Trigger: notifications.Trigger,
|
||||
},
|
||||
[discusssions.ID]: {
|
||||
ID: discusssions.ID,
|
||||
Sidebar: discusssions.Sidebar,
|
||||
Trigger: discusssions.Trigger,
|
||||
},
|
||||
};
|
||||
|
||||
export const SIDEBAR_ORDER = [
|
||||
discusssions.ID,
|
||||
notifications.ID,
|
||||
];
|
||||
@@ -1,20 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { WatchOutline } from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import messages from './messages';
|
||||
import messages from '../../../messages';
|
||||
|
||||
function NotificationIcon({ intl, status, notificationColor }) {
|
||||
function NotificationIcon({
|
||||
intl,
|
||||
status,
|
||||
notificationColor,
|
||||
}) {
|
||||
return (
|
||||
<div className="icon-container">
|
||||
<>
|
||||
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openNotificationTrigger)} />
|
||||
{status === 'active'
|
||||
? <span className={classNames(notificationColor, 'notification-dot')} data-testid="notification-dot" />
|
||||
? (
|
||||
<span
|
||||
className={classNames(notificationColor, 'rounded-circle p-1 position-absolute')}
|
||||
data-testid="notification-dot"
|
||||
style={{
|
||||
top: '0.3rem',
|
||||
right: '0.55rem',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.icon-container {
|
||||
width: 2.4rem;
|
||||
height: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import UpgradeNotification from '../../../../../generic/upgrade-notification/UpgradeNotification';
|
||||
|
||||
import messages from '../../../messages';
|
||||
import SidebarBase from '../../common/SidebarBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import NotificationTrigger, { ID } from './NotificationTrigger';
|
||||
|
||||
function NotificationTray({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
onNotificationSeen,
|
||||
shouldDisplayFullScreen,
|
||||
upgradeNotificationCurrentState,
|
||||
setUpgradeNotificationCurrentState,
|
||||
} = useContext(SidebarContext);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
contentTypeGatingEnabled,
|
||||
marketingUrl,
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
userTimezone,
|
||||
} = course;
|
||||
|
||||
const {
|
||||
org,
|
||||
verifiedMode,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
// After three seconds, update notificationSeen (to hide red dot)
|
||||
useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []);
|
||||
|
||||
return (
|
||||
<SidebarBase
|
||||
title={intl.formatMessage(messages.notificationTitle)}
|
||||
ariaLabel={intl.formatMessage(messages.notificationTray)}
|
||||
sidebarId={ID}
|
||||
className={classNames({ 'h-100': !verifiedMode && !shouldDisplayFullScreen })}
|
||||
>
|
||||
<div>{verifiedMode
|
||||
? (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
/>
|
||||
) : (
|
||||
<p className="p-3 small">{intl.formatMessage(messages.noNotificationsMessage)}</p>
|
||||
)}
|
||||
</div>
|
||||
</SidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTray.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
NotificationTray.Trigger = NotificationTrigger;
|
||||
NotificationTray.ID = ID;
|
||||
|
||||
export default injectIntl(NotificationTray);
|
||||
@@ -0,0 +1,160 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
fireEvent, initializeMockApp, render, screen, waitFor,
|
||||
} from '../../../../../setupTest';
|
||||
import initializeStore from '../../../../../store';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../../../../utils';
|
||||
|
||||
import { fetchCourse } from '../../../../data';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import { ID } from './index';
|
||||
import NotificationTray from './NotificationTray';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('NotificationTray', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultMetadata = Factory.build('courseMetadata');
|
||||
const courseId = defaultMetadata.id;
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const updatedCourseHomeMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, updatedCourseHomeMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
global.innerWidth = breakpoints.large.minWidth;
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
});
|
||||
|
||||
it('renders notification tray and close tray button', async () => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
toggleSidebar: toggleNotificationTray,
|
||||
shouldDisplayFullScreen: false,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(screen.getByText('Notifications'))
|
||||
.toBeInTheDocument();
|
||||
const notificationCloseIconButton = screen.getByRole('button', { name: /Close notification tray/i });
|
||||
expect(notificationCloseIconButton)
|
||||
.toBeInTheDocument();
|
||||
expect(notificationCloseIconButton)
|
||||
.toHaveClass('btn-icon-primary');
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray)
|
||||
.toHaveBeenCalledTimes(1);
|
||||
|
||||
// should not render responsive "Back to course" to close the tray
|
||||
expect(screen.queryByText('Back to course'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
|
||||
expect(UpgradeNotification)
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no notifications message if no verified mode', async () => {
|
||||
setMetadata({ verified_mode: null });
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(screen.queryByText('You have no new notifications at this time.'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks notification as seen 3 seconds later', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onNotificationSeen = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
onNotificationSeen,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
|
||||
jest.advanceTimersByTime(3000);
|
||||
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders notification tray with full screen "Back to course" at responsive view', async () => {
|
||||
global.innerWidth = breakpoints.medium.maxWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
shouldDisplayFullScreen: true,
|
||||
toggleSidebar: toggleNotificationTray,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
|
||||
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
|
||||
await waitFor(() => expect(responsiveCloseButton)
|
||||
.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(responsiveCloseButton);
|
||||
expect(toggleNotificationTray)
|
||||
.toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { getLocalStorage, setLocalStorage } from '../../../../../data/localStorage';
|
||||
import { getSessionStorage, setSessionStorage } from '../../../../../data/sessionStorage';
|
||||
import messages from '../../../messages';
|
||||
import SidebarTriggerBase from '../../common/TriggerBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
|
||||
import NotificationIcon from './NotificationIcon';
|
||||
|
||||
export const ID = 'NOTIFICATIONS';
|
||||
|
||||
function NotificationTrigger({
|
||||
intl,
|
||||
onClick,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
notificationStatus,
|
||||
setNotificationStatus,
|
||||
upgradeNotificationCurrentState,
|
||||
} = useContext(SidebarContext);
|
||||
|
||||
/* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages
|
||||
The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available,
|
||||
compare with the last state they've seen, and if it's different then set dot back to red */
|
||||
function UpdateUpgradeNotificationLastSeen() {
|
||||
if (upgradeNotificationCurrentState) {
|
||||
if (getLocalStorage(`upgradeNotificationLastSeen.${courseId}`) !== upgradeNotificationCurrentState) {
|
||||
setNotificationStatus('active');
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'active');
|
||||
setLocalStorage(`upgradeNotificationLastSeen.${courseId}`, upgradeNotificationCurrentState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!getLocalStorage(`notificationStatus.${courseId}`)) {
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'active'); // Show red dot on notificationTrigger until seen
|
||||
}
|
||||
|
||||
if (!getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)) {
|
||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'initialize');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
UpdateUpgradeNotificationLastSeen();
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
|
||||
} else {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
|
||||
}
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarTriggerBase onClick={handleClick} ariaLabel={intl.formatMessage(messages.openNotificationTrigger)}>
|
||||
<NotificationIcon status={notificationStatus} notificationColor="bg-danger-500" />
|
||||
</SidebarTriggerBase>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTrigger.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTrigger);
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import {
|
||||
render, initializeTestStore, screen, fireEvent,
|
||||
} from '../../setupTest';
|
||||
fireEvent, initializeTestStore, render, screen,
|
||||
} from '../../../../../setupTest';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
|
||||
describe('Notification Trigger', () => {
|
||||
@@ -12,7 +13,11 @@ describe('Notification Trigger', () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
|
||||
beforeEach(async () => {
|
||||
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
await initializeTestStore({
|
||||
courseMetadata,
|
||||
excludeFetchCourse: true,
|
||||
excludeFetchSequence: true,
|
||||
});
|
||||
mockData = {
|
||||
courseId: courseMetadata.id,
|
||||
toggleNotificationTray: () => {},
|
||||
@@ -31,13 +36,23 @@ describe('Notification Trigger', () => {
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
function renderWithProvider(data, onClick = () => {
|
||||
}) {
|
||||
const { container } = render(
|
||||
<SidebarContext.Provider value={{ ...mockData, ...data }}>
|
||||
<NotificationTrigger onClick={onClick} />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('handles onClick event toggling the notification tray', async () => {
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
render(<NotificationTrigger {...testData} />);
|
||||
renderWithProvider(testData, toggleNotificationTray);
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
@@ -46,52 +61,40 @@ describe('Notification Trigger', () => {
|
||||
});
|
||||
|
||||
it('renders notification trigger icon with red dot when notificationStatus is active', async () => {
|
||||
const { container } = render(<NotificationTrigger {...mockData} notificationStatus="active" />);
|
||||
const container = renderWithProvider({ notificationStatus: 'active' });
|
||||
expect(container).toBeInTheDocument();
|
||||
const buttonIcon = container.querySelectorAll('svg');
|
||||
expect(buttonIcon).toHaveLength(1);
|
||||
expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification trigger icon WITHOUT red dot 3 seconds later', async () => {
|
||||
const { container } = render(<NotificationTrigger {...mockData} notificationStatus="active" />);
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
|
||||
jest.useFakeTimers();
|
||||
setTimeout(() => {
|
||||
expect(localStorage.setItem).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole('notification-dot')).not.toBeInTheDocument();
|
||||
}, 3000);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
it('renders notification trigger icon WITHOUT red dot within the same phase', async () => {
|
||||
const { container } = render(
|
||||
<NotificationTrigger
|
||||
{...mockData}
|
||||
upgradeNotificationCurrentState="sameState"
|
||||
upgradeNotificationLastSeen="sameState"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`);
|
||||
expect(localStorage.getItem(`upgradeNotificationLastSeen.${mockData.courseId}`)).toBe('"sameState"');
|
||||
const container = renderWithProvider({
|
||||
upgradeNotificationLastSeen: 'sameState',
|
||||
upgradeNotificationCurrentState: 'sameState',
|
||||
});
|
||||
expect(container)
|
||||
.toBeInTheDocument();
|
||||
expect(localStorage.getItem)
|
||||
.toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`);
|
||||
expect(localStorage.getItem(`upgradeNotificationLastSeen.${mockData.courseId}`))
|
||||
.toBe('"sameState"');
|
||||
const buttonIcon = container.querySelectorAll('svg');
|
||||
expect(buttonIcon).toHaveLength(1);
|
||||
expect(screen.queryByRole('notification-dot')).not.toBeInTheDocument();
|
||||
expect(buttonIcon)
|
||||
.toHaveLength(1);
|
||||
expect(screen.queryByRole('notification-dot'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Rendering NotificationTrigger has the effect of calling UpdateUpgradeNotificationLastSeen(),
|
||||
// if upgradeNotificationLastSeen is different than upgradeNotificationCurrentState
|
||||
// it should update localStorage accordingly
|
||||
it('makes the right updates when rendering a new phase from an UpgradeNotification change (before -> after)', async () => {
|
||||
const { container } = render(
|
||||
<NotificationTrigger
|
||||
{...mockData}
|
||||
upgradeNotificationLastSeen="before"
|
||||
upgradeNotificationCurrentState="after"
|
||||
/>,
|
||||
);
|
||||
const container = renderWithProvider({
|
||||
upgradeNotificationLastSeen: 'before',
|
||||
upgradeNotificationCurrentState: 'after',
|
||||
});
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// verify localStorage get/set are called with correct arguments
|
||||
@@ -110,13 +113,10 @@ describe('Notification Trigger', () => {
|
||||
localStorage.setItem(`upgradeNotificationLastSeen.${courseMetadataSecondCourse.id}`, '"accessDateView"');
|
||||
localStorage.setItem(`notificationStatus.${courseMetadataSecondCourse.id}`, '"inactive"');
|
||||
|
||||
const { container } = render(
|
||||
<NotificationTrigger
|
||||
{...mockData}
|
||||
upgradeNotificationLastSeen="before"
|
||||
upgradeNotificationCurrentState="after"
|
||||
/>,
|
||||
);
|
||||
const container = renderWithProvider({
|
||||
upgradeNotificationLastSeen: 'before',
|
||||
upgradeNotificationCurrentState: 'after',
|
||||
});
|
||||
expect(container).toBeInTheDocument();
|
||||
// Verify localStorage was updated for the original course
|
||||
expect(localStorage.getItem(`upgradeNotificationLastSeen.${mockData.courseId}`)).toBe('"after"');
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Sidebar } from './NotificationTray';
|
||||
export { default as Trigger, ID } from './NotificationTrigger';
|
||||
@@ -6,6 +6,9 @@ Factory.define('courseMetadata')
|
||||
.extend(courseMetadataBase)
|
||||
.option('host', '')
|
||||
.attrs({
|
||||
access_expiration: {
|
||||
expiration_date: '2032-02-22T05:00:00Z',
|
||||
},
|
||||
content_type_gating_enabled: false,
|
||||
course_expired_message: null,
|
||||
course_goals: {
|
||||
@@ -29,14 +32,6 @@ Factory.define('courseMetadata')
|
||||
mode: null,
|
||||
is_active: null,
|
||||
},
|
||||
verified_mode: {
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
sku: '8CF08E5',
|
||||
price: 149,
|
||||
currency_symbol: '$',
|
||||
},
|
||||
show_calculator: false,
|
||||
license: 'all-rights-reserved',
|
||||
notes: {
|
||||
@@ -49,6 +44,13 @@ Factory.define('courseMetadata')
|
||||
course_exit_page_is_active: true,
|
||||
user_has_passing_grade: false,
|
||||
certificate_data: null,
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 0.0,
|
||||
entrance_exam_enabled: false,
|
||||
entrance_exam_id: '',
|
||||
entrance_exam_minimum_score_pct: 0.65,
|
||||
entrance_exam_passed: true,
|
||||
},
|
||||
verify_identity_url: null,
|
||||
verification_status: 'none',
|
||||
linkedin_add_to_profile_url: null,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
Factory.define('discussionTopic')
|
||||
.option('topicPrefix', null, '')
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic-${idx}`)
|
||||
.sequence('name', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic ${idx}`)
|
||||
.sequence(
|
||||
'usage_key',
|
||||
['id', 'courseId'],
|
||||
(idx, id, courseId) => `block-v1:${courseId.replace('course-v1:', '')}+type@vertical+block@${id}`,
|
||||
)
|
||||
.attr('enabled_in_context', null, true)
|
||||
.attr('thread_counts', [], {
|
||||
discussion: 0,
|
||||
question: 0,
|
||||
});
|
||||
|
||||
// Given a pre-build units state, build topics from it.
|
||||
export function buildTopicsFromUnits(units) {
|
||||
return Object.values(units).map(unit => Factory.build('discussionTopic', { usage_key: unit.id }));
|
||||
}
|
||||
@@ -2,3 +2,4 @@ import './courseMetadata.factory';
|
||||
import './sequenceMetadata.factory';
|
||||
import './courseRecommendations.factory';
|
||||
import './learningSequencesOutline.factory';
|
||||
import './discussionTopics.factory';
|
||||
|
||||
@@ -46,6 +46,7 @@ export function buildOutlineFromBlocks(courseBlocks) {
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
start: null,
|
||||
effective_start: null,
|
||||
sequence_ids: [...block.children],
|
||||
};
|
||||
} else if (block.type === 'sequential') {
|
||||
@@ -54,6 +55,7 @@ export function buildOutlineFromBlocks(courseBlocks) {
|
||||
title: block.display_name,
|
||||
accessible: true,
|
||||
start: null,
|
||||
effective_start: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { getTimeOffsetMillis } from '../../course-home/data/api';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
@@ -10,11 +10,17 @@ export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
sequences: {},
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
function isReleased(block) {
|
||||
// We check whether the backend marks this as accessible because staff users are granted access anyway.
|
||||
// Note that sections don't have the `accessible` field and will just be checking `effective_start`.
|
||||
return block.accessible || !block.effective_start || now >= Date.parse(block.effective_start);
|
||||
}
|
||||
|
||||
// Sequences
|
||||
Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => {
|
||||
if (!sequence.accessible) {
|
||||
// Skipping inaccessible sequences replicates the behavior of the legacy course blocks API
|
||||
return;
|
||||
if (!isReleased(sequence)) {
|
||||
return; // Don't let the learner see unreleased sequences
|
||||
}
|
||||
|
||||
models.sequences[seqId] = {
|
||||
@@ -26,39 +32,37 @@ export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
|
||||
// Sections
|
||||
learningSequencesData.outline.sections.forEach(section => {
|
||||
// Skipping sections with only inaccessible sequences replicates the behavior of the legacy course blocks API
|
||||
// (But keep it if it was already empty, again to replicate legacy blocks API.)
|
||||
const accessibleSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences);
|
||||
if (section.sequence_ids.length > 0 && accessibleSequenceIds.length === 0) {
|
||||
// Filter out any ignored sequences (e.g. unreleased sequences)
|
||||
const availableSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences);
|
||||
|
||||
// If we are unreleased and already stripped out all our children, just don't show us at all.
|
||||
// (We check both release date and children because children will exist for an unreleased section even for staff,
|
||||
// so we still want to show this section.)
|
||||
if (!isReleased(section) && availableSequenceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
models.sections[section.id] = {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
sequenceIds: accessibleSequenceIds,
|
||||
sequenceIds: availableSequenceIds,
|
||||
courseId: learningSequencesData.course_key,
|
||||
};
|
||||
|
||||
// Add back-references to this section for all child sequences.
|
||||
accessibleSequenceIds.forEach(childSeqId => {
|
||||
availableSequenceIds.forEach(childSeqId => {
|
||||
models.sequences[childSeqId].sectionId = section.id;
|
||||
});
|
||||
});
|
||||
|
||||
// Course
|
||||
const now = new Date();
|
||||
models.courses[learningSequencesData.course_key] = {
|
||||
id: learningSequencesData.course_key,
|
||||
title: learningSequencesData.title,
|
||||
sectionIds: Object.entries(models.sections).map(([sectionId]) => sectionId),
|
||||
|
||||
// Scan through all the sequences and look for ones that aren't accessible
|
||||
// to us yet because the start date has not yet passed. (Some may be
|
||||
// inaccessible because the end_date has passed.)
|
||||
hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(
|
||||
seq => !seq.accessible && now < Date.parse(seq.effective_start),
|
||||
),
|
||||
// Scan through all the sequences and look for ones that aren't released yet.
|
||||
hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(seq => !isReleased(seq)),
|
||||
};
|
||||
|
||||
return models;
|
||||
@@ -71,7 +75,7 @@ export async function getSequenceForUnitDeprecated(courseId, unitId) {
|
||||
url.searchParams.append('course_id', courseId);
|
||||
url.searchParams.append('username', authenticatedUser ? authenticatedUser.username : '');
|
||||
url.searchParams.append('depth', 3);
|
||||
url.searchParams.append('requested_fields', 'children');
|
||||
url.searchParams.append('requested_fields', 'children,discussions_url');
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
const parent = Object.values(data.blocks).find(block => block.type === 'sequential' && block.children.includes(unitId));
|
||||
@@ -84,17 +88,6 @@ export async function getLearningSequencesOutline(courseId) {
|
||||
return normalizeLearningSequencesData(data);
|
||||
}
|
||||
|
||||
function normalizeTabUrls(id, tabs) {
|
||||
// If api doesn't return the mfe base url, change tab url to point back to LMS
|
||||
return tabs.map((tab) => {
|
||||
let { url } = tab;
|
||||
if (url[0] === '/') {
|
||||
url = `${getConfig().LMS_BASE_URL}${tab.url}`;
|
||||
}
|
||||
return { ...tab, url };
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata) {
|
||||
const requestTime = Date.now();
|
||||
const responseTime = requestTime;
|
||||
@@ -106,9 +99,7 @@ function normalizeMetadata(metadata) {
|
||||
courseGoals: camelCaseObject(data.course_goals),
|
||||
id: data.id,
|
||||
title: data.name,
|
||||
number: data.number,
|
||||
offer: camelCaseObject(data.offer),
|
||||
org: data.org,
|
||||
enrollmentStart: data.enrollment_start,
|
||||
enrollmentEnd: data.enrollment_end,
|
||||
end: data.end,
|
||||
@@ -116,11 +107,7 @@ function normalizeMetadata(metadata) {
|
||||
enrollmentMode: data.enrollment.mode,
|
||||
isEnrolled: data.enrollment.is_active,
|
||||
canViewLegacyCourseware: data.can_view_legacy_courseware,
|
||||
originalUserIsStaff: data.original_user_is_staff,
|
||||
isStaff: data.is_staff,
|
||||
license: data.license,
|
||||
verifiedMode: camelCaseObject(data.verified_mode),
|
||||
tabs: normalizeTabUrls(data.id, camelCaseObject(data.tabs)),
|
||||
userTimezone: data.user_timezone,
|
||||
showCalculator: data.show_calculator,
|
||||
notes: camelCaseObject(data.notes),
|
||||
@@ -129,14 +116,13 @@ function normalizeMetadata(metadata) {
|
||||
userHasPassingGrade: data.user_has_passing_grade,
|
||||
courseExitPageIsActive: data.course_exit_page_is_active,
|
||||
certificateData: camelCaseObject(data.certificate_data),
|
||||
entranceExamData: camelCaseObject(data.entrance_exam_data),
|
||||
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
|
||||
verifyIdentityUrl: data.verify_identity_url,
|
||||
verificationStatus: data.verification_status,
|
||||
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
|
||||
relatedPrograms: camelCaseObject(data.related_programs),
|
||||
isIntegritySignatureEnabled: data.is_integrity_signature_enabled,
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
isMasquerading: data.original_user_is_staff && !data.is_staff,
|
||||
canAccessProctoredExams: data.can_access_proctored_exams,
|
||||
};
|
||||
}
|
||||
@@ -227,8 +213,21 @@ export async function postIntegritySignature(courseId) {
|
||||
);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function sendActivationEmail() {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/send_account_activation_email`);
|
||||
const { data } = await getAuthenticatedHttpClient().post(url.href, {});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCourseDiscussionConfig(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCourseTopics(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@ describe('Courseware Service', () => {
|
||||
expect(response).toEqual(normalizedOutline);
|
||||
});
|
||||
|
||||
it('skips inaccessible sequences', async () => {
|
||||
it('skips unreleased sequences', async () => {
|
||||
await provider.addInteraction({
|
||||
state: `Outline exists with inaccessible sequences for course_id ${courseId}`,
|
||||
state: `Outline exists with unreleased sequences for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to get an outline',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
@@ -97,18 +97,21 @@ describe('Courseware Service', () => {
|
||||
sections: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially accessible',
|
||||
title: 'Partially released',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
],
|
||||
effective_start: null,
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@nope',
|
||||
title: 'Wholly inaccessible',
|
||||
title: 'Wholly unreleased',
|
||||
sequence_ids: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
],
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
],
|
||||
sequences: {
|
||||
@@ -116,17 +119,23 @@ describe('Courseware Service', () => {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
title: 'Can access',
|
||||
accessible: true,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
title: 'Released and inaccessible',
|
||||
accessible: false,
|
||||
effective_start: '2019-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope1',
|
||||
title: 'Cannot access',
|
||||
title: 'Unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@nope2',
|
||||
title: 'Still cannot access',
|
||||
title: 'Still unreleased',
|
||||
accessible: false,
|
||||
effective_start: '9999-07-01T17:00:00Z',
|
||||
},
|
||||
@@ -149,10 +158,11 @@ describe('Courseware Service', () => {
|
||||
sections: {
|
||||
'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
title: 'Partially accessible',
|
||||
title: 'Partially released',
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
sequenceIds: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -163,6 +173,12 @@ describe('Courseware Service', () => {
|
||||
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@accessible?experience=legacy`,
|
||||
},
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released': {
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@released',
|
||||
title: 'Released and inaccessible',
|
||||
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@partial',
|
||||
legacyWebUrl: `${getConfig().LMS_BASE_URL}/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@released?experience=legacy`,
|
||||
},
|
||||
},
|
||||
};
|
||||
const response = await getLearningSequencesOutline(courseId);
|
||||
@@ -224,7 +240,6 @@ describe('Courseware Service', () => {
|
||||
}),
|
||||
license: string('all-rights-reserved'),
|
||||
name: like('Demonstration Course'),
|
||||
number: like('DemoX'),
|
||||
offer: {
|
||||
code: string('code'),
|
||||
expiration_date: term({
|
||||
@@ -236,16 +251,12 @@ describe('Courseware Service', () => {
|
||||
percentage: integer(50),
|
||||
upgrade_url: string('url'),
|
||||
},
|
||||
org: like('edX'),
|
||||
related_programs: null,
|
||||
short_description: like(''),
|
||||
start: term({
|
||||
generate: '2013-02-05T05:00:00Z',
|
||||
matcher: dateRegex,
|
||||
}),
|
||||
tabs: eachLike({
|
||||
title: 'Course', slug: 'courseware', priority: 0, type: 'courseware', url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}),
|
||||
user_timezone: null,
|
||||
verified_mode: like({
|
||||
access_expiration_date: term({
|
||||
@@ -272,11 +283,6 @@ describe('Courseware Service', () => {
|
||||
}),
|
||||
notes: { enabled: boolean(false), visible: boolean(true) },
|
||||
marketing_url: null,
|
||||
celebrations: {
|
||||
first_section: boolean(false),
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: boolean(false),
|
||||
},
|
||||
user_has_passing_grade: boolean(false),
|
||||
course_exit_page_is_active: boolean(false),
|
||||
certificate_data: {
|
||||
@@ -301,7 +307,6 @@ describe('Courseware Service', () => {
|
||||
contentTypeGatingEnabled: false,
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
title: 'Demonstration Course',
|
||||
number: 'DemoX',
|
||||
offer: {
|
||||
code: 'code',
|
||||
discountedPrice: '$99',
|
||||
@@ -310,7 +315,6 @@ describe('Courseware Service', () => {
|
||||
percentage: 50,
|
||||
upgradeUrl: 'url',
|
||||
},
|
||||
org: 'edX',
|
||||
enrollmentStart: '2013-02-05T05:00:00Z',
|
||||
enrollmentEnd: '2013-02-05T05:00:00Z',
|
||||
end: '2013-02-05T05:00:00Z',
|
||||
@@ -318,33 +322,11 @@ describe('Courseware Service', () => {
|
||||
enrollmentMode: 'audit',
|
||||
isEnrolled: true,
|
||||
canViewLegacyCourseware: true,
|
||||
originalUserIsStaff: true,
|
||||
isStaff: true,
|
||||
license: 'all-rights-reserved',
|
||||
verifiedMode: {
|
||||
accessExpirationDate: '2013-02-05T05:00:00Z',
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
tabs: [{
|
||||
title: 'Course',
|
||||
slug: 'courseware',
|
||||
priority: 0,
|
||||
type: 'courseware',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
}],
|
||||
userTimezone: null,
|
||||
showCalculator: false,
|
||||
notes: { enabled: false, visible: true },
|
||||
marketingUrl: null,
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
userHasPassingGrade: false,
|
||||
courseExitPageIsActive: false,
|
||||
certificateData: {
|
||||
@@ -359,7 +341,6 @@ describe('Courseware Service', () => {
|
||||
linkedinAddToProfileUrl: null,
|
||||
relatedPrograms: null,
|
||||
userNeedsIntegritySignature: false,
|
||||
isMasquerading: false,
|
||||
};
|
||||
const response = await getCourseMetadata(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].verifiedMode).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[courseId].marketingUrl).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should fetch, normalize, and save metadata; filtering has no effect', async () => {
|
||||
@@ -132,7 +132,7 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].verifiedMode).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[courseId].marketingUrl).not.toBeUndefined();
|
||||
expect(state.models.sequences.length === 1);
|
||||
|
||||
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 1));
|
||||
@@ -159,7 +159,7 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].verifiedMode).not.toBeUndefined();
|
||||
expect(state.models.coursewareMeta[courseId].marketingUrl).not.toBeUndefined();
|
||||
expect(state.models.sequences === null);
|
||||
|
||||
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 0));
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
import { getCourseHomeCourseMetadata } from '../../course-home/data/api';
|
||||
import {
|
||||
addModel, addModelsMap, updateModel, updateModels, updateModelsMap,
|
||||
} from '../../generic/model-store';
|
||||
import {
|
||||
getBlockCompletion,
|
||||
getCourseDiscussionConfig,
|
||||
getCourseMetadata,
|
||||
getCourseTopics,
|
||||
getLearningSequencesOutline,
|
||||
getSequenceMetadata,
|
||||
postIntegritySignature,
|
||||
postSequencePosition,
|
||||
} from './api';
|
||||
import { getCourseHomeCourseMetadata } from '../../course-home/data/api';
|
||||
import {
|
||||
updateModel, addModel, updateModelsMap, addModelsMap, updateModels,
|
||||
} from '../../generic/model-store';
|
||||
import {
|
||||
fetchCourseDenied,
|
||||
fetchCourseFailure,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
fetchSequenceFailure,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
@@ -27,7 +29,7 @@ export function fetchCourse(courseId) {
|
||||
Promise.allSettled([
|
||||
getCourseMetadata(courseId),
|
||||
getLearningSequencesOutline(courseId),
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getCourseHomeCourseMetadata(courseId, 'courseware'),
|
||||
]).then(([
|
||||
courseMetadataResult,
|
||||
learningSequencesOutlineResult,
|
||||
@@ -231,3 +233,23 @@ export function saveIntegritySignature(courseId, isMasquerading) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getCourseDiscussionTopics(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const config = await getCourseDiscussionConfig(courseId);
|
||||
// Only load topics for the openedx provider, the legacy provider uses
|
||||
// the xblock
|
||||
if (config.provider === 'openedx') {
|
||||
const topics = await getCourseTopics(courseId);
|
||||
dispatch(updateModels({
|
||||
modelType: 'discussionTopics',
|
||||
models: topics,
|
||||
idField: 'usageKey',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@ function SocialIcons({
|
||||
intl,
|
||||
socialMessage,
|
||||
}) {
|
||||
const { marketingUrl } = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
marketingUrl,
|
||||
org,
|
||||
title,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
if (!marketingUrl) {
|
||||
return null;
|
||||
|
||||
@@ -202,9 +202,11 @@ const initCoursewareMMP2P = (courseId, sequenceId, unitId) => {
|
||||
|
||||
const models = {
|
||||
coursewareMeta: state.models.coursewareMeta[courseId],
|
||||
courseHomeMeta: state.models.courseHomeMeta[courseId],
|
||||
units: state.models.units[unitId],
|
||||
};
|
||||
const { accessExpiration, verifiedMode } = models.coursewareMeta;
|
||||
const { accessExpiration } = models.coursewareMeta;
|
||||
const { verifiedMode } = models.courseHomeMeta;
|
||||
const graded = models.units !== undefined ? models.units.graded : false;
|
||||
|
||||
let access = {};
|
||||
|
||||
21
src/generic/hooks.js
Normal file
21
src/generic/hooks.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useEventListener(type, handler) {
|
||||
// We use this ref so that we can hold a reference to the currently active event listener.
|
||||
const eventListenerRef = useRef(null);
|
||||
useEffect(() => {
|
||||
// If we currently have an event listener, remove it.
|
||||
if (eventListenerRef.current !== null) {
|
||||
global.removeEventListener(type, eventListenerRef.current);
|
||||
eventListenerRef.current = null;
|
||||
}
|
||||
// Now add our new handler as the event listener.
|
||||
global.addEventListener(type, handler);
|
||||
// And then save it to our ref for next time.
|
||||
eventListenerRef.current = handler;
|
||||
// When the component finally unmounts, use the ref to remove the correct handler.
|
||||
return () => global.removeEventListener(type, eventListenerRef.current);
|
||||
}, [type, handler]);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
function add(state, modelType, model) {
|
||||
const { id } = model;
|
||||
function add(state, modelType, model, idField) {
|
||||
idField = idField ?? 'id';
|
||||
const id = model[idField];
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
state[modelType][id] = model;
|
||||
}
|
||||
|
||||
function update(state, modelType, model) {
|
||||
function update(state, modelType, model, idField) {
|
||||
idField = idField ?? 'id';
|
||||
const id = model[idField];
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
state[modelType][model.id] = { ...state[modelType][model.id], ...model };
|
||||
state[modelType][id] = { ...state[modelType][id], ...model };
|
||||
}
|
||||
|
||||
function remove(state, modelType, id) {
|
||||
@@ -29,28 +32,28 @@ const slice = createSlice({
|
||||
initialState: {},
|
||||
reducers: {
|
||||
addModel: (state, { payload }) => {
|
||||
const { modelType, model } = payload;
|
||||
add(state, modelType, model);
|
||||
const { modelType, model, idField } = payload;
|
||||
add(state, modelType, model, idField);
|
||||
},
|
||||
addModels: (state, { payload }) => {
|
||||
const { modelType, models } = payload;
|
||||
models.forEach(model => add(state, modelType, model));
|
||||
const { modelType, models, idField } = payload;
|
||||
models.forEach(model => add(state, modelType, model, idField));
|
||||
},
|
||||
addModelsMap: (state, { payload }) => {
|
||||
const { modelType, modelsMap } = payload;
|
||||
Object.values(modelsMap).forEach(model => add(state, modelType, model));
|
||||
const { modelType, modelsMap, idField } = payload;
|
||||
Object.values(modelsMap).forEach(model => add(state, modelType, model, idField));
|
||||
},
|
||||
updateModel: (state, { payload }) => {
|
||||
const { modelType, model } = payload;
|
||||
update(state, modelType, model);
|
||||
const { modelType, model, idField } = payload;
|
||||
update(state, modelType, model, idField);
|
||||
},
|
||||
updateModels: (state, { payload }) => {
|
||||
const { modelType, models } = payload;
|
||||
models.forEach(model => update(state, modelType, model));
|
||||
const { modelType, models, idField } = payload;
|
||||
models.forEach(model => update(state, modelType, model, idField));
|
||||
},
|
||||
updateModelsMap: (state, { payload }) => {
|
||||
const { modelType, modelsMap } = payload;
|
||||
Object.values(modelsMap).forEach(model => update(state, modelType, model));
|
||||
const { modelType, modelsMap, idField } = payload;
|
||||
Object.values(modelsMap).forEach(model => update(state, modelType, model, idField));
|
||||
},
|
||||
removeModel: (state, { payload }) => {
|
||||
const { modelType, id } = payload;
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function Tabs({ children, className, ...attrs }) {
|
||||
key="overflow"
|
||||
>
|
||||
<Dropdown className="h-100">
|
||||
<Dropdown.Toggle variant="link" className="nav-link h-100">
|
||||
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
|
||||
<FormattedMessage
|
||||
id="learn.course.tabs.navigation.overflow.menu"
|
||||
description="The title of the overflow menu for course tabs"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user