Compare commits

...

71 Commits

Author SHA1 Message Date
Ihor Romaniuk
72bce9e652 fix: save scroll position on exit from video xblock fullscreen mode (#981)
* fix: save scroll position on exit from video xblock fullscreen mode

* fix: change initial scroll top value

* fix: update tests
2023-06-21 13:45:34 -04:00
Eugene Dyudyunov
66482ab23a fix: first section celebration
Fix the first section celebration modal showing logic.

On Nutmeg+ it's shown only after the page reload or after going directly
to the second section from the course home. Going through the course
with the Next/Previous buttons has no effect (which worked on Maple).

Notes:
- the weekly goal has the same showing logic, but I assume that is
correct behavior so no changes are added for it in this commit.
- showing a celebration modal for the first section completion when
going directly to the first unit of the second section seems to be a bug
(reproduces on Maple too)
2023-02-14 15:40:50 -05:00
Ihor Romaniuk
03e0f41692 fix: fix alignment in the streak celebration modal (#974)
* fix: fix alignment in the streak celebration modal

* chore: update package-log
2022-12-05 11:54:48 -05:00
Diana Catalina Olarte
8e1904a235 fix: show site name instead of edX
(cherry picked from commit cafb881a61)
2022-05-18 15:52:10 +01:00
Michael Terry
88485a0f77 fix: add back es-check & fsevents for now to fix build
A previous commit (7f37575) dropped es-check, which dropped
fsevents, which caused our build system (which is still using
npm@6) to fail with an error like `Unsupported platform for
fsevents` when trying to install fsevents through a dependency
(e.g. when installing npm aliases).

I am reintroducing all the package-lock changes from that commit
to get back fsevents in a state where that error does not occur.

I think a longer-term fix would be to instead upgrade our build
system to node16 / npm6. But this is an easy fix for now to unblock
the builds.
2022-04-14 10:16:14 -04:00
edx-semantic-release
361a099ed1 chore(i18n): update translations 2022-04-10 17:08:59 -04:00
Adam Stankiewicz
7f3757539a build: use shared browserslist config and remove is-es5 check 2022-04-08 16:30:25 -04:00
Michael Terry
44f5132e2a fix: downgrade react and upgrade some other deps to align
Now that we are using node 16, peer dependencies are much more
strict about aligning between all of our dependencies.

This PR downgrades react from 17 to 16 (no changes) and upgrades
paragon and frontend-lib-special-exams to all be on the same
page about what peer dependency ranges are valid.
2022-04-08 16:00:29 -04:00
Renovate Bot
53b19c9be3 chore(deps): update codecov/codecov-action action to v3 2022-04-07 15:24:08 -04:00
edX requirements bot
abc374b60a chore!: Dropped support for Node 12 2022-04-07 15:15:04 -04:00
Muhammad Soban Javed
af837fcac8 fix: run npm i with npm 8 to update lock file version (#915) 2022-04-06 16:41:04 +05:00
Renovate Bot
e328e3d597 chore(deps): update dependency @testing-library/jest-dom to v5.16.4 2022-04-05 18:39:33 +00:00
Renovate Bot
559160213d fix(deps): update dependency @popperjs/core to v2.11.5 2022-04-05 14:53:55 +00:00
Renovate Bot
878a4616f3 fix(deps): update dependency react-redux to v7.2.8 2022-04-04 16:02:20 +00:00
Renovate Bot
3028d79597 fix(deps): update dependency @edx/frontend-component-header to v2.4.6 2022-04-04 15:44:31 +00:00
alangsto
aa0de7663c chore: upgrade special exams lib version (#906) 2022-04-04 06:41:51 -07:00
edx-semantic-release
acd91a1c31 chore(i18n): update translations 2022-04-04 07:10:20 -04:00
Usama Sadiq
b32817b3dd build: update transifex pull translations command (#903) 2022-04-04 16:04:32 +05:00
Thomas Tracy
8b32e5892f chore: [MICROBA-1780] missing copy edit (#902)
We missed a copy edit from the previous PR made for this ticket. Also
changing the id to something more relevant to the message.
2022-04-01 14:28:58 -04:00
Thomas Tracy
76cf85f3d7 chore: [MICROBA-1780] Copy edits (#901)
A partner was not happy with messaging for a course whose
students were in the "earned-not-available" state. This aims to make the
messaging more clear.
2022-04-01 12:43:05 -04:00
Renovate Bot
7d86c501a7 fix(deps): update dependency react-redux to v7.2.7 2022-03-31 22:18:26 +00:00
Renovate Bot
eeee32c100 fix(deps): update dependency @reduxjs/toolkit to v1.8.1 2022-03-31 22:02:34 +00:00
Renovate Bot
95d88a054e fix(deps): update dependency @edx/frontend-platform to v1.15.6 2022-03-31 21:43:23 +00:00
edX requirements bot
550b15a16c fix: transifex migration to new client 2022-03-31 13:33:29 -04:00
Renovate Bot
715393d6ad chore(deps): update dependency @edx/reactifex to v1.1.0 2022-03-31 11:34:28 -04:00
Renovate Bot
19b4241020 fix(deps): update dependency @edx/paragon to v19.13.6 2022-03-31 09:34:02 -04:00
Renovate Bot
09bd5bd748 chore(deps): update dependency ansi-regex to 3.0.1 [security] 2022-03-31 09:32:44 -04:00
Renovate Bot
89771cb56b chore(deps): update dependency @edx/frontend-build to v9.1.4 2022-03-31 02:23:12 +00:00
Michael Terry
3353ee2f9d fix(deps): update dependency @edx/paragon to v19.13.1 2022-03-28 11:30:08 -04:00
Renovate Bot
7c1821382c fix(deps): update dependency @reduxjs/toolkit to v1.8.0 2022-03-28 11:12:22 -04:00
Ghassan Maslamani
b444d677b7 fix: import i18n messages from header for learning to consume
It just makes the learning consume the i18n of the header, as
  otherwise the learning would show the deafult messages of the
  header.
2022-03-28 10:55:44 -04:00
dependabot[bot]
7178f28838 chore(deps): bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 10:44:40 -04:00
Renovate Bot
b07f22193c chore(deps): update dependency ansi-regex to 4.1.1 [security] 2022-03-28 10:43:42 -04:00
Renovate Bot
c6eba42120 chore(deps): update dependency @testing-library/jest-dom to v5.16.3 2022-03-28 09:27:16 -04:00
Michael Terry
7bb2266790 fix: stop stripping most inaccessible sequences from navigation
I had previously made a "fix" to strip all inaccessible sequences
from the learning sequence outline hierarachy, as a way to filter
out unreleased sequences. See commit d1f19a9.

But that was too big a hammer and stripped a lot of released-but-
inaccessible sequences too (e.g. prerequisites).

So now, we adopt a more nuanced approach and explicitly just filter
out sequences that are both inaccessible AND unreleased.

AA-1219
2022-03-28 09:16:05 -04:00
edX Transifex Bot
0a70f9b64e chore(i18n): update translations 2022-03-27 17:08:29 -04:00
Renovate Bot
cfe4432c6b fix(deps): update dependency @edx/frontend-component-footer to v10.2.2 2022-03-24 13:47:25 +00:00
Chris Deery
f7219b4f5d fix: [AA-1219] Crash when locked content rendered without unit ID (#878)
Allows SequenceNavigation to complete rendering even if unitId is not provided.
2022-03-22 17:35:45 -04:00
Renovate Bot
14a19b2794 fix(deps): update dependency @edx/frontend-platform to v1.15.5 2022-03-21 20:21:41 +00:00
edX Transifex Bot
8a9767cdd3 chore(i18n): update translations 2022-03-20 17:08:15 -04:00
Chris Deery
3cba1bbac4 fix: [AA-1207] Remove redundant API fields (#873)
Remove redundant fields from courseware API. These are all found in courseHome: 

- number
- org
- originalUserIsStaff
- isStaff
- verifiedMode
- isMasquerading (virtual field from isStaff and originalUserIsStaff)
2022-03-18 09:20:31 -04:00
Chris Deery
9436770620 fix: improve Guard for Iframe resize (#875)
Fix line that was causing JS errors when it was called in a context without a valid document.
2022-03-17 14:47:19 -04:00
Renovate Bot
d03dd34009 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.18 2022-03-16 20:57:50 +00:00
Renovate Bot
9cdacde4dc chore(deps): update dependency @pact-foundation/pact to v9.17.3 2022-03-16 15:54:09 +00:00
Renovate Bot
a22ac3a776 fix(deps): update dependency @edx/frontend-platform to v1.15.3 2022-03-14 21:29:02 +00:00
Michael Terry
7e19af44da fix: trust the course grade the LMS gives us for progress page
Rather than recompute it ourselves. Now that the backend is fixed
to consider only visible grades for the course grade, we don't need
to try to work around its logic (which is more accurate/consistent
in general).

AA-1217
2022-03-14 13:04:06 -04:00
Dillon Dumesnil
57c3f3080e feat: AA-1205: Enable Entrance Exam support for Learning MFE (#840)
Adds an alert to the courseware if the section is an Entrance Exam. Also
adds a listener to reload the page upon receiving a message from the LMS
indicating the user has now passed the exam.

Commit also contains misc. clean up for i18n messages switching to variable names.
2022-03-14 08:09:15 -07:00
Renovate Bot
385635f5d1 fix(deps): update dependency @popperjs/core to v2.11.4 2022-03-14 11:59:19 +00:00
edX Transifex Bot
a7f763cd2a chore(i18n): update translations 2022-03-13 17:08:02 -04:00
Renovate Bot
c7c9c19771 fix(deps): update dependency @popperjs/core to v2.11.3 2022-03-12 15:29:22 +00:00
julianajlk
1d3a779ef1 feat: Add past expiration messaging for UpgradeNotification (#853)
REV-2500
2022-03-11 10:37:30 -05:00
julianajlk
4f1a50ec24 feat: Add Value Prop past expiration messaging for gated content (#836)
REV-2500
2022-03-11 09:58:44 -05:00
Chris Deery
72d18dc4f9 fix: [AA-1207] unify source of tabs (#861)
Courseware and courseHome both provide tabs to the mfe.
This PR unifies the calls so that tab descriptions are only fetched from courseHome metadata

Remove jest-chain dependencies to make test errors more usable.
2022-03-10 13:29:30 -05:00
Renovate Bot
2197ec0c21 fix(deps): update dependency @edx/paragon to v19.7.0 2022-03-10 09:28:37 -05:00
Renovate Bot
069ac9c234 chore(deps): update dependency @testing-library/react to v12.1.4 2022-03-10 09:00:20 -05:00
Renovate Bot
3edf349969 fix(deps): update dependency @edx/frontend-platform to v1.15.2 2022-03-08 17:41:52 +00:00
Michael Terry
a2516e9fcc fix: avoid a race condition with redux data on course exit page
I would sometimes see a case where we were trying to access
recommendations data before it was defined.
2022-03-07 15:03:03 -05:00
Renovate Bot
554806e9ce chore(deps): update actions/setup-node action to v3 2022-03-07 14:18:38 -05:00
Renovate Bot
ed13128fc4 fix(deps): update dependency prop-types to v15.8.1 2022-03-07 13:40:23 -05:00
Renovate Bot
373a2d88fc fix(deps): update dependency @edx/frontend-component-header to v2.4.5 2022-03-07 13:40:07 -05:00
Renovate Bot
bcd54a4f4b fix(deps): update font awesome 2022-03-07 13:39:44 -05:00
Renovate Bot
c4cb0e5ac2 chore(deps): update actions/checkout action to v3 2022-03-07 13:39:26 -05:00
Renovate Bot
c77d518d04 fix(deps): update dependency core-js to v3.21.1 2022-03-07 13:39:12 -05:00
Renovate Bot
703250c3d2 fix(deps): update dependency @edx/frontend-component-footer to v10.2.1 2022-03-07 16:52:26 +00:00
Michael Terry
35ec314505 chore: drop unused frontend-enterprise-utils dependency 2022-03-07 11:35:50 -05:00
Michael Terry
9fc7951576 fix: downgrade frontend-build to fix our builds
For a still-unknown reason, 9.1.2 breaks our builds in GoCD.
While that investigation continues, let's drop down to a known
working version.
2022-03-07 11:35:50 -05:00
Michael Terry
4ed350c9c6 chore: update testing-library/react to latest 12.x
Also, drop our specific testing-library/dom dependency. We can
get it transitively though testing-library/react, and they are
coupled closely enough that we don't want them out of lockstep
anyway.

Thus, this commit also updates testing-library/dom to 8.x,
because that's what testing-library/react 12.x needs.
2022-03-07 11:35:50 -05:00
Michael Terry
ebed27529c fix: adjust timer test to work again 2022-03-07 11:35:50 -05:00
Michael Terry
24ced5dc63 chore: update testing-library/dom version and clean dev deps
Get on the latest 7.x release and remove some unused dev deps:
- enzyme
- enzyme-adapter-react-17
- glob
2022-03-07 11:35:50 -05:00
Kshitij Sobti
f004d0ab3c feat: Sidebar refactor and add support for discussions sidebar. (#762)
squash!: remove unnecessary styling and migrate to bootstrap and other review feedback
2022-03-07 18:56:05 +05:00
edX Transifex Bot
1bbcc6d052 chore(i18n): update translations 2022-03-06 16:07:49 -05:00
118 changed files with 30999 additions and 6024 deletions

1
.env
View File

@@ -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=''

View File

@@ -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=''

View File

@@ -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=''

View File

@@ -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
View File

@@ -0,0 +1 @@
*

31
.husky/_/husky.sh Normal file
View 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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View 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 };

View 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;

View File

@@ -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',
},
),
],
);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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.

View File

@@ -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);
});

View File

@@ -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';

View File

@@ -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
});
});

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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>
);
}

View File

@@ -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('Youre 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: [

View File

@@ -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 }}

View File

@@ -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: {

View File

@@ -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 ? '' : '-';

View File

@@ -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' : '';

View File

@@ -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}`}>

View File

@@ -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');
});
});

View File

@@ -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) {

View File

@@ -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>
);
}

View File

@@ -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());
});
});
});

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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]);

View File

@@ -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) {

View File

@@ -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: {

View 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();
});
});

View File

@@ -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);

View File

@@ -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 = (

View File

@@ -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!"
/>

View File

@@ -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,

View File

@@ -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}`);
});
});
});

View File

@@ -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

View File

@@ -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

View File

@@ -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={{

View File

@@ -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 = (

View File

@@ -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 = (

View File

@@ -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: {

View File

@@ -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 },

View File

@@ -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);

View File

@@ -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({

View File

@@ -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({

View File

@@ -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' };

View File

@@ -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 && (

View File

@@ -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);

View File

@@ -1,5 +1,6 @@
.alert-content.lock-paywall-container {
display: inline-flex;
width: 100%;
}
.lock-paywall-container svg {

View File

@@ -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',
});
});
});

View File

@@ -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',

View File

@@ -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;

View File

@@ -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);

View 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;

View File

@@ -0,0 +1,5 @@
import React from 'react';
const SidebarContext = React.createContext({});
export default SidebarContext;

View 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,
};

View 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;

View 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);

View 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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './DiscussionsSidebar';
export { default as Trigger, ID } from './DiscussionsTrigger';

View File

@@ -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;

View 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,
];

View File

@@ -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>
</>
);
}

View File

@@ -0,0 +1,4 @@
.icon-container {
width: 2.4rem;
height: 2rem;
}

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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"');

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './NotificationTray';
export { default as Trigger, ID } from './NotificationTrigger';

View File

@@ -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,

View File

@@ -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 }));
}

View File

@@ -2,3 +2,4 @@ import './courseMetadata.factory';
import './sequenceMetadata.factory';
import './courseRecommendations.factory';
import './learningSequencesOutline.factory';
import './discussionTopics.factory';

View File

@@ -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,
};
}
});

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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));

View File

@@ -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);
}
};
}

View File

@@ -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;

View File

@@ -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
View 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]);
}

View File

@@ -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;

View File

@@ -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