Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Terry
4d67042660 fix: handle new 403 "redirect" responses from backend
To make it easier to redirect the user to an another page or an
access denied error page, this PR deprecates the old custom 401 &
404 error handling, replacing them both with a generic 403 error
code that contains a URL to redirect the user to.

Before this change, we weren't handling all the error cases, and
when we didn't, we ended up showing a broken page to the user.
2021-07-20 13:30:27 -04:00
300 changed files with 14696 additions and 47995 deletions

14
.env
View File

@@ -1,21 +1,11 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
CONTACT_URL=''
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=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
@@ -25,13 +15,12 @@ LOGOUT_URL=''
LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
LEGACY_THEME_NAME=''
FAVICON_URL=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL=''
SEGMENT_KEY=''
SESSION_COOKIE_DOMAIN=''
SITE_NAME=''
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
STUDIO_BASE_URL=''
@@ -43,3 +32,4 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
SESSION_COOKIE_DOMAIN=''

View File

@@ -1,21 +1,11 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
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=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
@@ -24,7 +14,7 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
@@ -42,4 +32,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
SESSION_COOKIE_DOMAIN='localhost'

View File

@@ -1,21 +1,11 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
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=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
@@ -24,7 +14,7 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000

View File

@@ -1,19 +0,0 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,10 +0,0 @@
# Run commitlint on the commit messages in a pull request.
name: Lint Commit Messages
on:
- pull_request
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master

View File

@@ -1,24 +1,21 @@
name: validate
on:
push:
branches:
- master
pull_request:
branches:
- '**'
- push
- pull_request
jobs:
tests:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
node-version:
- 12
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
node-version: ${{ matrix.node-version }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true

4
.gitignore vendored
View File

@@ -8,7 +8,6 @@ coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
logs
### pyenv ###
.python-version
@@ -20,6 +19,3 @@ logs
# Local package dependencies
module.config.js
# Local environment overrides
.env.private

1
.husky/_/.gitignore vendored
View File

@@ -1 +0,0 @@
*

View File

@@ -1,31 +0,0 @@
#!/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,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

View File

@@ -1,9 +1,8 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-learning]
[edx-platform.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

@@ -1,9 +1,11 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_resource = frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -36,15 +38,15 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
@@ -58,6 +60,7 @@ validate:
npm run lint -- --max-warnings 0
npm run test
npm run build
npm run is-es5
.PHONY: validate.ci
validate.ci:

View File

@@ -1,21 +1,23 @@
|codecov| |license|
|Coveralls| |npm_version| |npm_downloads| |license|
frontend-app-learning
=========================
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
Introduction
------------
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
actual course content, etc).
React app for edX learning.
Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-learning
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
Development
-----------
@@ -23,10 +25,22 @@ Development
Start Devstack
^^^^^^^^^^^^^^
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms``
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
- Start devstack
- Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In this project, install requirements and start the development server by running:
.. code:: bash
npm install
npm start # The server will run on port 1995
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Local module development
^^^^^^^^^^^^^^^^^^^^^^^^
@@ -53,69 +67,3 @@ file (which is git-ignored) that defines where to find your local modules, for i
};
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment
----------
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
as documented in the Open edX Developer Guide under
`Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`_.
The learning micro-frontend also supports the following additional variables:
CREDIT_HELP_LINK_URL
A link to resources to help explain what course credit is and how to earn it.
ENABLE_JUMPNAV
Enables the new Jump Navigation feature in the course breadcrumbs, defaulted to the string 'true'.
Disable to have simple hyperlinks for breadcrumbs. Setting it to any other value but 'true' ('false','I love flags', 'etc' would disable the Jumpnav).
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
https://openedx.atlassian.net/browse/TNL-8678
SOCIAL_UTM_MILESTONE_CAMPAIGN
This value is passed as the ``utm_campaign`` parameter for social-share
links when celebrating learning milestones in the course. Optional.
Example: ``milestone``
SUPPORT_URL_CALCULATOR_MATH
A link that explains how to use the in-course calculator. You can use the
one in the example below, if you don't want to have your own branded version.
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
SUPPORT_URL_ID_VERIFICATION
A link that explains how to verify your ID. Shown in contexts where you need
to verify yourself to earn a certificate. The example link below is probably too
edx.org-specific to use for your own site.
Example: https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity
SUPPORT_URL_VERIFIED_CERTIFICATE
A link that explains what a verified certificate is. You can use the
one in the example below, if you don't want to have your own branded version.
Optional.
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
TWITTER_HASHTAG
This value is used in the Twitter social-share link when celebrating learning
milestones in the course. Will prefill the suggested post with this hashtag.
Optional.
Example: ``brandedhashtag``
TWITTER_URL
A link to your Twitter account. The Twitter social-share link won't appear
unless this is set. Optional.
Example: https://twitter.com/edXOnline

View File

@@ -1,7 +1,5 @@
# Courseware Page Decisions
**See [0009-courseware-api-direction.md](0009-courseware-api-direction.md) for updates!**
## Courseware data loading
Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components.

View File

@@ -1,62 +0,0 @@
# Direction of Courseware APIs
In order to allow for greater flexibility and separation of concerns, we're going to stop using the Course Blocks API for navigational data, and pull that data from the Learning Sequences Outlines API instead. The intention is to give us four distinct layers of courseware that can eventually be composed in different ways:
* Learning Context Metadata
* Learning Context Navigation
* Sequence Navigation
* Unit Rendering
Note that "Learning Context" is a generalization of "Course" that includes other things like Content Libraries, Learning Pathways, and potentially other logical groupings of content.
This is a refinement of [0002-courseware-page-decisions.md](0002-courseware-page-decisions.md). The fundamental layers remain the same, but this document tries to better clarify the boundaries and path forward for these layers. We're not making these layers completely swappable/pluggable now, but we should separate the data access in a way that allows for that in the future.
## Background
We currently make four primary requests to the LMS when rendering courseware instructional content:
1. Course Metadata: `/api/courseware/course/{courseId}` (REST API)
2. Course Blocks API: `/api/courses/v2/blocks/?course_id={courseId}` (REST API)
3. Sequence Metadata: `/api/courseware/sequence/{sequenceUsageKey}` (REST API)
4. Unit: `/xblock/{unitBlockUsageKey}` (rendered in an iframe)
There is a significant amount of overlap between the Course Blocks API and the others at the moment, since Course Blocks takes a static snapshot of the entire tree of course content at once. There are a few problems with the current arrangement:
* It's slow and complex. The Course Blocks API can be difficult to maintain and reason about, and trickier to optimize.
* Assuming that all course structures are the same makes it difficult to support other content types, like LabXchange Learning Pathways or adaptive content.
* The overlap between Course Blocks and the other APIs means that there can be conflicts about the state.
## Motivating Vision
We have seen a desire to extend or enhance the courseware experience in various ways:
Learning Context Navigation
* Allowing for shorter, human-readable URLs in courseware.
* Smaller courses that do not need the current navigational hierarchy.
* LabXchange pathways.
Sequence Navigation
* Adaptive content, where the full list of units is not known up front.
* More limited navigation, where content is pushed linearly, without the ability to jump ahead.
* Different layouts for content browsing.
Unit Rendering
* Use of QTI content (currently serviced by cc2olx conversion).
* Desire to experiment with a next-gen version of XBlock.
* Use of entirely LTI units.
The idea would be to insulate each layer from the layers above and below it. Sequence rendering shouldn't be affected by whether or not it's in a two level hierarchy (Course → Section → Sequence), or a flat one (Course → Sequence). Learning Context Navigation should be able to reference Sequences without caring if a Sequence is an adaptive one or not. Sequences should be able to have a common interface to call Unit iframes, whether those Units are rendering XBlocks or QTI content.
Note that supporting these types of course structures would require downstream changes in other systems as well (e.g. analytics).
## Next Step: Removing use of the Course Blocks API.
The next step in this process is to remove the call to the Course Blocks API, and split its responsibilities across just the existing Learning Sequences Outline and Sequence Metadata APIs. This will involve at least a couple of steps.
### Complete rollout of Learning Sequences Outline calls.
We're currently in a transitional state between these APIs where the Learning Sequences Outline calls are only rolled out on a small handful of courses.
### Shift Sequence and Unit metadata to only come from Sequence Metadata API.
We currently pull this information from both Course Blocks and the Sequence Metadata API. We can consolidate on just the Sequence Metadata API. There is also server side optimization that can be done with the Sequence Metadata API calls as part of this work.

45977
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,24 @@
"url": "git+https://github.com/edx/frontend-app-learning.git"
},
"browserslist": [
"extends @edx/browserslist-config"
"last 2 versions",
"ie 11"
],
"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",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
@@ -30,49 +36,49 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@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.18",
"@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1",
"core-js": "3.21.1",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.8",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"reselect": "4.1.5",
"truncate-html": "1.0.4",
"util": "0.12.4"
"@edx/frontend-component-footer": "10.1.5",
"@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-lib-special-exams": "1.9.0",
"@edx/frontend-platform": "1.11.0",
"@edx/paragon": "15.2.2",
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/react-fontawesome": "0.1.14",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
"js-cookie": "2.2.1",
"lodash.camelcase": "^4.3.0",
"prop-types": "15.7.2",
"react": "16.13.1",
"react-break": "1.3.2",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"react-redux": "7.2.4",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-share": "4.2.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"reselect": "4.0.0",
"truncate-html": "1.0.3"
},
"devDependencies": {
"@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",
"axios-mock-adapter": "1.20.0",
"codecov": "3.8.3",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "27.5.1",
"rosie": "2.1.0"
"@edx/frontend-build": "5.5.5",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "12.0.17",
"axios-mock-adapter": "1.18.2",
"codecov": "3.8.2",
"es-check": "5.1.4",
"glob": "7.1.7",
"husky": "3.1.0",
"jest": "24.9.0",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.0.1"
}
}

View File

@@ -5,7 +5,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
<% } %>

View File

@@ -4,11 +4,12 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
import AccessExpirationAlertMasquerade from './AccessExpirationAlertMasquerade';
function AccessExpirationAlert({ intl, payload }) {
/** [MM-P2P] Experiment */
@@ -35,10 +36,17 @@ function AccessExpirationAlert({ intl, payload }) {
const {
expirationDate,
masqueradingExpiredCourse,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
if (masqueradingExpiredCourse) {
return (
<AccessExpirationAlertMasquerade payload={payload} />
);
}
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (
@@ -65,7 +73,6 @@ function AccessExpirationAlert({ intl, payload }) {
<FormattedMessage
id="learning.accessExpiration.deadline"
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
values={{
date: (
<FormattedDate
@@ -93,12 +100,11 @@ function AccessExpirationAlert({ intl, payload }) {
}
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold">
<FormattedMessage
id="learning.accessExpiration.header"
defaultMessage="Audit Access Expires {date}"
description="Headline for auditing deadline"
values={{
date: (
<FormattedDate
@@ -117,7 +123,6 @@ function AccessExpirationAlert({ intl, payload }) {
<FormattedMessage
id="learning.accessExpiration.body"
defaultMessage="You lose all access to this course, including your progress, on {date}."
description="Message body to tell learner the consequences of course expiration."
values={{
date: (
<FormattedDate

View File

@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
function AccessExpirationAlertMMP2P({ payload }) {
@@ -52,7 +52,7 @@ function AccessExpirationAlertMMP2P({ payload }) {
}
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold">
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
</span>

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
function AccessExpirationAlertMasquerade({ payload }) {
const {
accessExpiration,
userTimezone,
} = payload;
if (!accessExpiration) {
return null;
}
const {
expirationDate,
masqueradingExpiredCourse,
} = accessExpiration;
if (!masqueradingExpiredCourse) {
return null;
}
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.accessExpiration.expired"
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
values={{
date: (
<FormattedDate
key="accessExpirationExpiredDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
AccessExpirationAlertMasquerade.propTypes = {
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationAlertMasquerade;

View File

@@ -1,39 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
function AccessExpirationMasqueradeBanner({ payload }) {
const {
expirationDate,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<PageBanner variant="warning">
<FormattedMessage
id="instructorToolbar.pageBanner.courseHasExpired"
defaultMessage="This learner no longer has access to this course. Their access expired on {date}."
description="It's a warning that is shown to course author when being masqueraded as learner, while the course has expired for the real learner."
values={{
date: <FormattedDate
key="instructorToolbar.pageBanner.accessExpirationDate"
value={expirationDate}
{...timezoneFormatArgs}
/>,
}}
/>
</PageBanner>
);
}
AccessExpirationMasqueradeBanner.propTypes = {
payload: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default AccessExpirationMasqueradeBanner;

View File

@@ -1,12 +1,11 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
const AccessExpirationAlertMasquerade = React.lazy(() => import('./AccessExpirationAlertMasquerade'));
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
const isVisible = !!accessExpiration; // If it exists, show it.
const payload = {
accessExpiration,
courseId,
@@ -24,28 +23,20 @@ function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone,
return { clientAccessExpirationAlert: AccessExpirationAlert };
}
export function useAccessExpirationMasqueradeBanner(courseId, tab) {
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
accessExpiration,
} = useModel(tab, courseId);
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
const expirationDate = accessExpiration && accessExpiration.expirationDate;
export function useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, topic) {
const isVisible = !!accessExpiration; // If it exists, show it.
const payload = {
expirationDate,
accessExpiration,
userTimezone,
};
useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner',
code: 'clientAccessExpirationAlertMasquerade',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
topic,
});
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
return { clientAccessExpirationAlertMasquerade: AccessExpirationAlertMasquerade };
}
export default useAccessExpirationAlert;

View File

@@ -1 +1 @@
export { default, useAccessExpirationMasqueradeBanner } from './hooks';
export { default, useAccessExpirationAlertMasquerade } from './hooks';

View File

@@ -4,7 +4,6 @@ const messages = defineMessages({
upgradeNow: {
id: 'learning.accessExpiration.upgradeNow',
defaultMessage: 'Upgrade now',
description: 'The anchor text for the upgrading link',
},
});

View File

@@ -1,44 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
function CourseStartMasqueradeBanner({ payload }) {
const {
courseId,
} = payload;
const {
start,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<PageBanner variant="warning">
<FormattedMessage
id="instructorToolbar.pageBanner.courseHasNotStarted"
defaultMessage="This learner does not yet have access to this course. The course starts on {date}."
description="It's a warning that is shown to course author when being masqueraded as learner, while the course hasn't started for the real learner yet."
values={{
date: <FormattedDate
key="instructorToolbar.pageBanner.courseStartDate"
value={start}
{...timezoneFormatArgs}
/>,
}}
/>
</PageBanner>
);
}
CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string.isRequired,
}).isRequired,
};
export default CourseStartMasqueradeBanner;

View File

@@ -1,62 +0,0 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
function isStartDateInFuture(courseId) {
const {
start,
} = useModel('courseHomeMeta', courseId);
const today = new Date();
const startDate = new Date(start);
return startDate > today;
}
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const isVisible = isEnrolled && isStartDateInFuture(courseId);
const payload = {
courseId,
};
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCourseStartAlert: CourseStartAlert,
};
}
export function useCourseStartMasqueradeBanner(courseId, tab) {
const {
isMasquerading,
} = useModel('courseHomeMeta', courseId);
const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
const payload = {
courseId,
};
useAlert(isVisible, {
code: 'clientCourseStartMasqueradeBanner',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
});
return {
clientCourseStartMasqueradeBanner: CourseStartMasqueradeBanner,
};
}
export default useCourseStartAlert;

View File

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

View File

@@ -1,15 +1,15 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Button } from '@edx/paragon';
import { Info, WarningFilled } from '@edx/paragon/icons';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useModel } from '../../generic/model-store';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
import useEnrollClickHandler from './clickHook';
import { useEnrollClickHandler } from './hooks';
function EnrollmentAlert({ intl, payload }) {
const {
@@ -30,29 +30,27 @@ function EnrollmentAlert({ intl, payload }) {
);
let text = intl.formatMessage(messages.alert);
let type = 'warning';
let icon = WarningFilled;
let type = ALERT_TYPES.ERROR;
if (isStaff) {
text = intl.formatMessage(messages.staffAlert);
type = 'info';
icon = Info;
type = ALERT_TYPES.INFO;
} else if (extraText) {
text = `${text} ${extraText}`;
}
const button = canEnroll && (
<Button disabled={loading} variant="link" className="p-0 border-0 align-top mx-1" size="sm" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
{intl.formatMessage(messages.enrollNowSentence)}
</Button>
);
return (
<Alert variant={type} icon={icon}>
<div className="d-flex">
{text}
{button}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</div>
<Alert type={type}>
{text}
{' '}
{button}
{' '}
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert>
);
}

View File

@@ -1,35 +0,0 @@
import { useContext, useState, useCallback } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages';
import { postCourseEnrollment } from './data/api';
// Separated into its own file to avoid a circular dependency inside this directory
function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}
export default useEnrollClickHandler;

View File

@@ -1,12 +1,15 @@
/* eslint-disable import/prefer-default-export */
import React, {
useContext, useMemo,
useContext, useState, useCallback, useMemo,
} from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { useAlert } from '../../generic/user-messages';
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
@@ -37,3 +40,28 @@ export function useEnrollmentAlert(courseId) {
return { clientEnrollmentAlert: EnrollmentAlert };
}
export function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}

View File

@@ -9,13 +9,10 @@ import {
Icon,
} from '@edx/paragon';
import { Check, ArrowForward } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
function AccountActivationAlert({
intl,
}) {
function AccountActivationAlert() {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
@@ -32,12 +29,22 @@ function AccountActivationAlert({
if (showAccountActivationAlert !== undefined) {
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
// of cookie would make it infinite rendering
// of cookie would make it infinit rendering
if (Cookies.get('show-account-activation-popup') === undefined) {
setShowModal(true);
}
}
const title = (
<h3>
<FormattedMessage
id="account-activation.alert.title"
defaultMessage="Activate your account so you can log back in"
description="Title for account activation alert which is shown after the registration"
/>
</h3>
);
const button = (
<Button
variant="primary"
@@ -57,7 +64,7 @@ function AccountActivationAlert({
);
const children = () => {
let bodyContent;
let bodyContent = null;
const message = (
<FormattedMessage
id="account-activation.alert.message"
@@ -116,7 +123,7 @@ function AccountActivationAlert({
return (
<AlertModal
isOpen={showModal}
title={intl.formatMessage(messages.accountActivationAlertTitle)}
title={title}
footerNode={button}
onClose={() => ({})}
>
@@ -125,8 +132,4 @@ function AccountActivationAlert({
);
}
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AccountActivationAlert);

View File

@@ -2,9 +2,9 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import { Hyperlink } from '@edx/paragon';
import { Alert } from '../../generic/user-messages';
import genericMessages from '../../generic/messages';
function LogistrationAlert({ intl }) {
@@ -29,7 +29,7 @@ function LogistrationAlert({ intl }) {
);
return (
<Alert variant="warning" icon={WarningFilled}>
<Alert type="error">
<FormattedMessage
id="learning.logistration.alert"
description="Prompts the user to sign in or register to see course content."

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
accountActivationAlertTitle: {
id: 'account-activation.alert.title',
defaultMessage: 'Activate your account so you can log back in',
description: 'Title for account activation alert which is shown after the registration',
},
});
export default messages;

View File

@@ -1,56 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -0,0 +1,34 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import genericMessages from '../generic/messages';
function AnonymousUserMenu({ intl }) {
return (
<div>
<Button
className="mr-3"
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(genericMessages.registerSentenceCase)}
</Button>
<Button
variant="primary"
href={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(genericMessages.signInSentenceCase)}
</Button>
</div>
);
}
AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AnonymousUserMenu);

View File

@@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from './messages';
function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username }) {
let dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
</Dropdown.Item>
);
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
dashboardMenuItem = (
<Dropdown.Item
href={enterpriseLearnerPortalLink.href}
>
{enterpriseLearnerPortalLink.content}
</Dropdown.Item>
);
}
return (
<>
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Dropdown className="user-dropdown">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{dashboardMenuItem}
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
{intl.formatMessage(messages.profile)}
</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
{intl.formatMessage(messages.account)}
</Dropdown.Item>
{!enterpriseLearnerPortalLink && (
// Users should only see Order History if they do not have an available
// learner portal, because an available learner portal currently means
// that they access content via Subscriptions, in which context an "order"
// is not relevant.
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
{intl.formatMessage(messages.orderHistory)}
</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{intl.formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
}
AuthenticatedUserDropdown.propTypes = {
enterpriseLearnerPortalLink: PropTypes.string,
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
AuthenticatedUserDropdown.defaultProps = {
enterpriseLearnerPortalLink: '',
};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -10,8 +10,8 @@ function CourseTabsNavigation({
activeTabSlug, className, tabs, intl,
}) {
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<div className={classNames('course-tabs-navigation', className)}>
<div className="container-fluid">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}

View File

@@ -23,10 +23,12 @@ describe('Course Tabs Navigation', () => {
};
render(<CourseTabsNavigation {...mockData} />);
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[0].title }))
.toHaveAttribute('href', tabs[0].url)
.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');
expect(screen.getByRole('link', { name: tabs[1].title }))
.toHaveAttribute('href', tabs[1].url)
.not.toHaveClass('active');
});
});

View File

@@ -0,0 +1,97 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages';
function LinkedLogo({
href,
src,
alt,
...attributes
}) {
return (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
}
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
function Header({
courseOrg, courseNumber, courseTitle, intl,
}) {
const { authenticatedUser } = useContext(AppContext);
const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig(
authenticatedUser,
getConfig().ENTERPRISE_LEARNER_PORTAL_HOSTNAME,
getConfig().LMS_BASE_URL,
);
let headerLogo = (
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
);
if (enterpriseCustomerBrandingConfig && Object.keys(enterpriseCustomerBrandingConfig).length > 0) {
headerLogo = (
<LinkedLogo
className="logo"
href={enterpriseCustomerBrandingConfig.logoDestination}
src={enterpriseCustomerBrandingConfig.logo}
alt={enterpriseCustomerBrandingConfig.logoAltText}
/>
);
}
return (
<header className="course-header">
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<div className="container-fluid py-2 d-flex align-items-center">
{headerLogo}
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
{authenticatedUser && (
<AuthenticatedUserDropdown
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
username={authenticatedUser.username}
/>
)}
{!authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
}
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
};
export default injectIntl(Header);

View File

@@ -0,0 +1,29 @@
import React from 'react';
import {
authenticatedUser, initializeMockApp, render, screen,
} from '../setupTest';
import { Header } from './index';
describe('Header', () => {
beforeAll(async () => {
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
await initializeMockApp();
});
it('displays user button', () => {
render(<Header />);
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
});
it('displays course data', () => {
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
render(<Header {...courseData} />);
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
});
});

View File

@@ -1,2 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as Header } from './Header';
export { default as CourseTabsNavigation } from './CourseTabsNavigation';

View File

@@ -0,0 +1,46 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
id: 'learn.navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
dashboard: {
id: 'header.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'header.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'header.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'header.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'header.menu.orderHistory.label',
defaultMessage: 'Order History',
description: 'The text for the user menu Order History navigation link.',
},
skipNavLink: {
id: 'header.navigation.skipNavLink',
defaultMessage: 'Skip to main content.',
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
},
signOut: {
id: 'header.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
});
export default messages;

View File

@@ -1,4 +1,5 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
Factory.define('courseHomeMetadata')
@@ -8,117 +9,5 @@ Factory.define('courseHomeMetadata')
title: 'Demonstration Course',
is_self_paced: false,
is_enrolled: false,
is_staff: false,
can_load_courseware: true,
celebrations: null,
course_access: {
additional_context_user_message: null,
developer_message: null,
error_code: null,
has_access: true,
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',
},
),
],
);
can_load_courseware: false,
});

View File

@@ -219,4 +219,5 @@ Factory.define('datesTabData')
],
has_ended: false,
learner_is_full_access: true,
user_timezone: 'America/New_York',
});

View File

@@ -14,6 +14,7 @@ Factory.define('outlineTabData')
})
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
course_date_blocks: dateBlocks,
user_timezone: 'UTC',
}))
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
has_visited_course: false,
@@ -40,9 +41,6 @@ Factory.define('outlineTabData')
course_goals: {
goal_options: [],
selected_goal: null,
weekly_learning_goal_enabled: false,
days_per_week: null,
subscribed_to_reminders: null,
},
course_tools: [
{
@@ -50,6 +48,11 @@ Factory.define('outlineTabData')
title: 'Bookmarks',
url: 'https://example.com/bookmarks',
},
{
analytics_id: 'edx.tool.verified_upgrade',
title: 'Upgrade to Verified',
url: 'https://example.com/upgrade',
},
],
dates_banner_info: {
content_type_gating_enabled: false,

View File

@@ -4,7 +4,6 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
// This set of data may not be realistic, but it is intended to demonstrate many UI results.
Factory.define('progressTabData')
.attrs({
access_expiration: null,
end: '3027-03-31T00:00:00Z',
certificate_data: {},
completion_summary: {
@@ -17,7 +16,6 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
credit_course_requirements: null,
section_scores: [
{
display_name: 'First section',
@@ -26,12 +24,10 @@ Factory.define('progressTabData')
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 0,
num_points_possible: 3,
num_points_possible: 1,
percent_graded: 0.0,
problem_scores: [{ earned: 0, possible: 1 }, { earned: 0, possible: 1 }, { earned: 0, possible: 1 }],
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
@@ -48,7 +44,6 @@ Factory.define('progressTabData')
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
problem_scores: [{ earned: 1, possible: 1 }],
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',

View File

@@ -5,9 +5,9 @@ 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')
.attr('verifiedMode', ['host'], (host) => ({
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
@@ -17,9 +17,4 @@ Factory.define('upgradeNotificationData')
upgradeUrl: `${host}/dashboard`,
}))
.attr('org', 'edX')
.attrs({
accessExpiration: {
expiration_date: '1950-07-13T02:04:49.040006Z',
},
})
.attr('timeOffsetMillis', 0);

View File

@@ -3,9 +3,9 @@
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"gradesFeatureIsLocked": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -14,79 +14,64 @@ Object {
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
"price": 10,
"upgradeUrl": "test",
},
},
},
"dates": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseDateBlocks": Array [
Object {
"date": "2020-05-01T17:59:41Z",
@@ -300,30 +285,24 @@ Object {
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true,
"userTimezone": "America/New_York",
},
},
},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"gradesFeatureIsLocked": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -332,79 +311,64 @@ Object {
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
"price": 10,
"upgradeUrl": "test",
},
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": Object {
@@ -416,8 +380,10 @@ Object {
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"effortActivities": undefined,
"effortTime": undefined,
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
@@ -427,7 +393,9 @@ Object {
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"effortActivities": 2,
"effortTime": 15,
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
@@ -441,8 +409,8 @@ Object {
"complete": false,
"description": null,
"due": null,
"effortActivities": 2,
"effortTime": 15,
"effortActivities": undefined,
"effortTime": undefined,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
@@ -453,11 +421,8 @@ Object {
},
},
"courseGoals": Object {
"daysPerWeek": null,
"goalOptions": Array [],
"selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
},
"courseTools": Array [
Object {
@@ -465,6 +430,11 @@ Object {
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
Object {
"analyticsId": "edx.tool.verified_upgrade",
"title": "Upgrade to Verified",
"url": "https://example.com/upgrade",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
@@ -473,24 +443,22 @@ Object {
},
"datesWidget": Object {
"courseDateBlocks": Array [],
"userTimezone": "UTC",
},
"enableProctoredExams": undefined,
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"offer": null,
"resumeCourse": Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"timeOffsetMillis": 0,
"userHasPassingGrade": undefined,
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
@@ -506,22 +474,15 @@ Object {
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"gradesFeatureIsLocked": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -530,80 +491,64 @@ Object {
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
"price": 10,
"upgradeUrl": "test",
},
},
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"course-v1:edX+DemoX+Demo_Course_1": Object {
"certificateData": Object {},
"completionSummary": Object {
"completeCount": 1,
@@ -614,17 +559,15 @@ Object {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
"visiblePercent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course",
"creditCourseRequirements": null,
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": "1.00",
"averageGrade": 1,
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
@@ -637,7 +580,7 @@ Object {
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionScores": Array [
Object {
"displayName": "First section",
@@ -647,24 +590,9 @@ Object {
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
"hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0,
"numPointsPossible": 3,
"numPointsPossible": 1,
"percentGraded": 0,
"problemScores": Array [
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
@@ -681,12 +609,6 @@ Object {
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": Array [
Object {
"earned": 1,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
@@ -708,12 +630,5 @@ Object {
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
}
`;

View File

@@ -15,10 +15,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
// exists in edx-platform.
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
averageGrade = points.reduce((a, b) => a + b, 0) / points.length;
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
@@ -90,25 +87,17 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
});
}
/**
* 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) {
function normalizeCourseHomeCourseMetadata(metadata) {
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.
// If needed, we switch it to "outline" here for
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
@@ -122,6 +111,8 @@ export function normalizeOutlineBlocks(courseId, blocks) {
switch (block.type) {
case 'course':
models.courses[block.id] = {
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: courseId,
title: block.display_name,
sectionIds: block.children || [],
@@ -132,6 +123,8 @@ export function normalizeOutlineBlocks(courseId, blocks) {
case 'chapter':
models.sections[block.id] = {
complete: block.complete,
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: block.id,
title: block.display_name,
resumeBlock: block.resume_block,
@@ -189,11 +182,31 @@ export function normalizeOutlineBlocks(courseId, blocks) {
return models;
}
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
function processTabDataErrorRedirect(error) {
const { httpErrorResponseData, httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 403) {
// Currently, only 403 errors contain redirect content
try {
const { redirect } = JSON.parse(httpErrorResponseData);
if (redirect) {
global.location.replace(redirect);
return true;
}
} catch (exc) {
// ignore any json parse errors, might be an actual 403 without redirect json content
}
}
// Did not do any redirecting
return false;
}
export async function getCourseHomeCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
return normalizeCourseHomeCourseMetadata(data);
}
// For debugging purposes, you might like to see a fully loaded dates tab.
@@ -202,27 +215,32 @@ export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
// import './__factories__';
export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
// TODO: remove this - not needed once the backend uses 403s for redirects
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
return null;
}
// 401 can be returned for unauthenticated users or users who are not enrolled
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
// TODO: remove this - not needed once the backend uses 403s for redirects
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
return null;
}
if (processTabDataErrorRedirect(error)) {
return null; // keeps loading screen active
}
throw error;
}
}
export async function getProgressTabData(courseId, targetUserId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
// If targetUserId is passed in, we will get the progress page data
// for the user with the provided id, rather than the requesting user.
@@ -239,49 +257,44 @@ 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.
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
camelCasedData.gradesFeatureIsPartiallyLocked = false;
if (camelCasedData.gradesFeatureIsFullyLocked) {
camelCasedData.sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
// If something is eligible to be gated by content type gating and would show up on the progress page
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
// since the learner has access to some (but not all) content that would normally be locked
if (subsection.learnerHasAccess) {
camelCasedData.gradesFeatureIsPartiallyLocked = true;
camelCasedData.gradesFeatureIsFullyLocked = false;
}
}
});
});
}
return camelCasedData;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
// TODO: remove this - not needed once the backend uses 403s for redirects
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
return null;
}
// 401 can be returned for unauthenticated users or users who are not enrolled
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
// TODO: remove this - not needed once the backend uses 403s for redirects
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
return null;
}
if (processTabDataErrorRedirect(error)) {
return null; // keeps loading screen active
}
throw error;
}
}
export async function getProctoringInfoData(courseId, username) {
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
@@ -313,7 +326,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
let requestTime = Date.now();
let responseTime = requestTime;
@@ -324,8 +337,12 @@ export async function getOutlineTabData(courseId) {
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
// TODO: remove this - not needed once the backend uses 403s for redirects
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
return null;
}
if (processTabDataErrorRedirect(error)) {
return null; // keeps loading screen active
}
throw error;
}
@@ -343,16 +360,13 @@ export async function getOutlineTabData(courseId) {
const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget);
const enableProctoredExams = data.enable_proctored_exams;
const enrollAlert = camelCaseObject(data.enroll_alert);
const enrollmentMode = data.enrollment_mode;
const handoutsHtml = data.handouts_html;
const hasScheduledContent = data.has_scheduled_content;
const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer);
const resumeCourse = camelCaseObject(data.resume_course);
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
@@ -366,15 +380,12 @@ export async function getOutlineTabData(courseId) {
datesBannerInfo,
datesWidget,
enrollAlert,
enrollmentMode,
enableProctoredExams,
handoutsHtml,
hasScheduledContent,
hasEnded,
offer,
resumeCourse,
timeOffsetMillis, // This should move to a global time correction reference
userHasPassingGrade,
verifiedMode,
welcomeMessageHtml,
};
@@ -388,22 +399,13 @@ export async function postCourseDeadlines(courseId, model) {
});
}
export async function deprecatedPostCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
}
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, {
course_id: courseId,
days_per_week: daysPerWeek,
subscribed_to_reminders: subscribedToReminders,
});
}
export async function postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
}
@@ -419,9 +421,3 @@ export async function executePostFromPostEvent(postData, researchEventData) {
research_event_data: researchEventData,
});
}
export async function unsubscribeFromCourseGoal(token) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}

View File

@@ -3,8 +3,7 @@ export {
fetchOutlineTab,
fetchProgressTab,
resetDeadlines,
deprecatedSaveCourseGoal,
saveWeeklyLearningGoal,
saveCourseGoal,
} from './thunks';
export { reducer } from './slice';

View File

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

View File

@@ -18,7 +18,7 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = courseHomeMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
let store;
@@ -31,7 +31,7 @@ describe('Data layer integration tests', () => {
});
describe('Test fetchDatesTab', () => {
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates`;
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
@@ -60,7 +60,7 @@ describe('Data layer integration tests', () => {
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
@@ -89,7 +89,7 @@ describe('Data layer integration tests', () => {
});
describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
@@ -133,10 +133,10 @@ describe('Data layer integration tests', () => {
describe('Test saveCourseGoal', () => {
it('Should save course goal', async () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
axiosMock.onPost(goalUrl).reply(200, {});
await thunks.deprecatedSaveCourseGoal(courseId, 'unsure');
await thunks.saveCourseGoal(courseId, 'unsure');
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
@@ -164,7 +164,7 @@ describe('Data layer integration tests', () => {
describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => {
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`;
const dismissUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`;
axiosMock.onPost(dismissUrl).reply(201);
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);

View File

@@ -4,30 +4,18 @@ import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'course-home',
initialState: {
courseStatus: 'loading',
courseId: null,
proctoringPanelStatus: 'loading',
gradesFeatureIsLocked: false,
toastBodyText: null,
toastBodyLink: null,
toastHeader: '',
},
reducers: {
fetchProctoringInfoResolved: (state) => {
state.proctoringPanelStatus = LOADED;
},
fetchTabDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
@@ -37,6 +25,10 @@ const slice = createSlice({
state.targetUserId = payload.targetUserId;
state.courseStatus = LOADED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
setCallToActionToast: (state, { payload }) => {
const {
header,
@@ -47,16 +39,18 @@ const slice = createSlice({
state.toastBodyText = linkText;
state.toastHeader = header;
},
setGradesFeatureStatus: (state, { payload }) => {
state.gradesFeatureIsLocked = payload.gradesFeatureIsLocked;
},
},
});
export const {
fetchProctoringInfoResolved,
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
setCallToActionToast,
setGradesFeatureStatus,
} = slice.actions;
export const {

View File

@@ -7,8 +7,7 @@ import {
getOutlineTabData,
getProgressTabData,
postCourseDeadlines,
deprecatedPostCourseGoals,
postWeeklyLearningGoal,
postCourseGoals,
postDismissWelcomeMessage,
postRequestCert,
} from './api';
@@ -18,7 +17,6 @@ import {
} from '../../generic/model-store';
import {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
@@ -33,7 +31,7 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId, 'outline'),
getCourseHomeCourseMetadata(courseId),
getTabData(courseId, targetUserId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
@@ -51,6 +49,11 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData && tabDataResult.value === null) {
// null tab data indicates that we have redirected elsewhere - just exit and don't visibly stop loading
return;
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
@@ -63,10 +66,7 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
logError(tabDataResult.reason);
}
// Disable the access-denied path for now - it caused a regression
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId }));
} else {
dispatch(fetchTabFailure({ courseId }));
@@ -110,12 +110,8 @@ export function resetDeadlines(courseId, model, getTabData) {
};
}
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
return deprecatedPostCourseGoals(courseId, goalKey);
}
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
export async function saveCourseGoal(courseId, goalKey) {
return postCourseGoals(courseId, goalKey);
}
export function processEvent(eventData, getTabData) {

View File

@@ -23,35 +23,30 @@ jest.mock('@edx/frontend-platform/analytics');
describe('DatesTab', () => {
let axiosMock;
let store;
let component;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
});
const store = initializeStore();
const component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
const datesTabData = Factory.build('datesTabData');
let courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
let courseMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = courseMetadata;
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {
courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
@@ -79,6 +74,7 @@ describe('DatesTab', () => {
describe('when receiving a full set of dates data', () => {
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
@@ -140,12 +136,13 @@ describe('DatesTab', () => {
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
await waitForElementToBeRemoved(tooltip); // and it's gone again
waitForElementToBeRemoved(tooltip); // and it's gone again
});
});
describe('Suggested schedule messaging', () => {
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
setMetadata({ is_self_paced: true, is_enrolled: true });
history.push(`/course/${courseId}/dates`);
});
@@ -298,55 +295,4 @@ describe('DatesTab', () => {
});
});
});
describe('when receiving an access denied error', () => {
// These tests could go into any particular tab, as they all go through the same flow. But dates tab works.
async function renderDenied(errorCode) {
setMetadata({
course_access: {
has_access: false,
error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
},
});
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
}
beforeEach(() => {
axiosMock.onGet(datesUrl).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
});
it('redirects to course survey for a survey_required error code', async () => {
await renderDenied('survey_required');
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
});
it('redirects to dashboard for an unfulfilled_milestones error code', async () => {
await renderDenied('unfulfilled_milestones');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
});
it('redirects to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
await renderDenied('audit_expired');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
});
it('redirects to the dashboard with a notlive start date for a course_not_started error code', async () => {
await renderDenied('course_not_started');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?notlive=2/5/2013'); // date from factory
});
it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
});
});

View File

@@ -4,37 +4,30 @@ const messages = defineMessages({
completed: {
id: 'learning.dates.badge.completed',
defaultMessage: 'Completed',
description: 'shown as label for the assignments which learner has completed.',
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due next',
description: 'Shown as label for the assignment which date is in the future',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past due',
description: 'Shown as label for the assignments which deadline has passed',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important dates',
description: 'The title of dates tab (course timeline).',
},
today: {
id: 'learning.dates.badge.today',
defaultMessage: 'Today',
description: 'Label used when the scheduled date for the assignment matches the current day',
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not yet released',
description: 'Shown as label for assignments which date is unknown yet',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified only',
description: 'Shown as label for assignments which learner has no access to.',
},
});

View File

@@ -29,10 +29,10 @@ function Day({
const {
courseId,
} = useSelector(state => state.courseHome);
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
} = useModel('dates', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items);

View File

@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState({});
// We don't need to bother with redux for this simple page. We're not sharing state with other pages at all.
useEffect(() => {
unsubscribeFromCourseGoal(token)
.then(
(result) => {
setIsLoading(false);
setData(result.data);
},
() => {
setIsLoading(false);
setError(true);
},
);
// We unfortunately have no information about the user, course, org, or really anything
// as visiting this page is allowed to be done anonymously and without the context of the course.
// The token can be used to connect a user and course, it will just require some post-processing
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
}, []); // deps=[] to only run once
return (
<>
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
)}
{!isLoading && (
<ResultPage error={error} courseTitle={data.courseTitle} />
)}
</main>
</>
);
}
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react';
import GoalUnsubscribe from './GoalUnsubscribe';
import { act, initializeMockApp } from '../../setupTest';
import initializeStore from '../../store';
import { UserMessagesProvider } from '../../generic/user-messages';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('GoalUnsubscribe', () => {
let axiosMock;
let store;
let component;
const unsubscribeUrl = `${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/TOKEN`;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
</UserMessagesProvider>
</AppProvider>
);
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
});
it('starts with a spinner', () => {
render(component);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('loads a real token', async () => {
const response = { course_title: 'My Sample Course' };
axiosMock.onPost(unsubscribeUrl).reply(200, response);
await act(async () => render(component));
expect(screen.getByText('Youve unsubscribed from goal reminders')).toBeInTheDocument();
expect(screen.getByText(/your goal for My Sample Course/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to dashboard' }))
.toHaveAttribute('href', 'http://localhost:18000/dashboard');
});
it('loads a bad token with an error page', async () => {
axiosMock.onPost(unsubscribeUrl).reply(404, {});
await act(async () => render(component));
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Go to dashboard' }))
.toHaveAttribute('href', 'http://localhost:18000/dashboard');
expect(screen.getByRole('link', { name: 'contact support' }))
.toHaveAttribute('href', 'http://localhost:18000/contact');
});
});

View File

@@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@edx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
function ResultPage({ courseTitle, error, intl }) {
const errorDescription = (
<FormattedMessage
id="learning.goals.unsubscribe.errorDescription"
defaultMessage="We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help."
values={{
contactSupport: (
<Hyperlink
className="text-reset"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().CONTACT_URL}`}
>
{intl.formatMessage(messages.contactSupport)}
</Hyperlink>
),
}}
/>
);
const header = error
? intl.formatMessage(messages.errorHeader)
: intl.formatMessage(messages.header);
const description = error
? errorDescription
: intl.formatMessage(messages.description, { courseTitle });
return (
<>
<UnsubscribeIcon className="text-primary" alt="" />
<div role="heading" aria-level="1" className="h2">{header}</div>
<div className="row justify-content-center">
<div className="col-xl-7 col-12 p-0">{description}</div>
</div>
<Button variant="brand" href={`${getConfig().LMS_BASE_URL}/dashboard`} className="mt-4">
{intl.formatMessage(messages.goToDashboard)}
</Button>
</>
);
}
ResultPage.defaultProps = {
courseTitle: null,
error: false,
};
ResultPage.propTypes = {
courseTitle: PropTypes.string,
error: PropTypes.bool,
intl: intlShape.isRequired,
};
export default injectIntl(ResultPage);

View File

@@ -1,3 +0,0 @@
import GoalUnsubscribe from './GoalUnsubscribe';
export default GoalUnsubscribe;

View File

@@ -1,36 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
contactSupport: {
id: 'learning.goals.unsubscribe.contact',
defaultMessage: 'contact support',
description: 'Its shown as a suggestion or recommendation for learner when their unsubscribing request has failed',
},
description: {
id: 'learning.goals.unsubscribe.description',
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.',
description: 'It describes the consequences to learner when they unsubscribe of goal reminder service',
},
errorHeader: {
id: 'learning.goals.unsubscribe.errorHeader',
defaultMessage: 'Something went wrong',
description: 'It indicate that the unsubscribing request has failed',
},
goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard',
description: 'Anchor text for button that redirects to dashboard page',
},
header: {
id: 'learning.goals.unsubscribe.header',
defaultMessage: 'Youve unsubscribed from goal reminders',
description: 'It indicate that the unsubscribing request was successful',
},
loading: {
id: 'learning.goals.unsubscribe.loading',
defaultMessage: 'Unsubscribing…',
description: 'Message shown when the unsubscribing request is processing',
},
});
export default messages;

View File

@@ -1,5 +0,0 @@
<svg width="167" height="153" viewBox="0 0 167 153" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M140.25 25.5H12.75V127.5H140.25V25.5ZM127.5 46L76.5 77.875L25.5 46V38.25L76.5 70.125L127.5 38.25V46Z" fill="currentColor"/>
<circle cx="134" cy="39" r="33" transform="rotate(-90 134 39)" fill="white"/>
<path d="M134 11C118.544 11 106 23.544 106 39C106 54.456 118.544 67 134 67C149.456 67 162 54.456 162 39C162 23.544 149.456 11 134 11ZM134 61.4C121.624 61.4 111.6 51.376 111.6 39C111.6 33.82 113.364 29.06 116.332 25.28L147.72 56.668C143.94 59.636 139.18 61.4 134 61.4ZM151.668 52.72L120.28 21.332C124.06 18.364 128.82 16.6 134 16.6C146.376 16.6 156.4 26.624 156.4 39C156.4 44.18 154.636 48.94 151.668 52.72Z" fill="#D23228"/>
</svg>

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -40,7 +40,7 @@ export default function DateSummary({
};
return (
<li className="p-0 mb-3 small text-dark-500">
<li className="container p-0 mb-3 small text-dark-500">
<div className="row">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
<div className="ml-1 font-weight-bold">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
@@ -13,40 +13,22 @@ export default function LmsHtmlFragment({
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
</head>
<body class="${className}">${html}</body>
<script>
const resizer = new ResizeObserver(() => {
window.parent.postMessage({type: 'lmshtmlfragment.resize'}, '*');
});
resizer.observe(document.body);
</script>
</html>
`;
const iframe = useRef(null);
function resetIframeHeight() {
if (iframe?.current?.contentWindow?.document?.body) {
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
}
function handleLoad() {
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
}
useEffect(() => {
function receiveMessage(event) {
const { type } = event.data;
if (type === 'lmshtmlfragment.resize') {
resetIframeHeight();
}
}
global.addEventListener('message', receiveMessage);
}, []);
return (
<iframe
className="w-100 border-0"
onLoad={resetIframeHeight}
onLoad={handleLoad}
ref={iframe}
referrerPolicy="origin"
scrolling="no"

View File

@@ -1,26 +1,27 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Button, Toast } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts';
import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard';
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data';
import genericMessages from '../../generic/messages';
import messages from './messages';
import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import useCourseEndAlert from './alerts/course-end-alert';
import useCourseStartAlert from '../../alerts/course-start-alert';
import useCourseStartAlert from './alerts/course-start-alert';
import usePrivateCourseAlert from './alerts/private-course-alert';
import useScheduledContentAlert from './alerts/scheduled-content-alert';
import { useModel } from '../../generic/model-store';
@@ -34,14 +35,13 @@ import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const {
courseId,
proctoringPanelStatus,
} = useSelector(state => state.courseHome);
const {
isSelfPaced,
org,
title,
userTimezone,
username,
} = useModel('courseHomeMeta', courseId);
const {
@@ -51,23 +51,25 @@ function OutlineTab({ intl }) {
sections,
},
courseGoals: {
goalOptions,
selectedGoal,
weeklyLearningGoalEnabled,
} = {},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
userTimezone,
},
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
enableProctoredExams,
offer,
timeOffsetMillis,
verifiedMode,
} = useModel('outline', courseId);
const {
marketingUrl,
} = useModel('coursewareMeta', courseId);
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
const [goalToastHeader, setGoalToastHeader] = useState('');
const [expandAll, setExpandAll] = useState(false);
const eventProperties = {
@@ -75,7 +77,16 @@ function OutlineTab({ intl }) {
courserun_key: courseId,
};
const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
...eventProperties,
event_type: hasVisitedCourse ? 'resume' : 'start',
url: resumeCourseUrl,
});
};
// Below the course title alerts (appearing in the order listed here)
const accessExpirationAlertMasquerade = useAccessExpirationAlertMasquerade(accessExpiration, userTimezone, 'outline-course-alerts');
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
@@ -96,25 +107,29 @@ function OutlineTab({ intl }) {
});
};
const isEnterpriseUser = () => {
const authenticatedUser = getAuthenticatedUser();
const userRoleNames = authenticatedUser ? authenticatedUser.roles.map(role => role.split(':')[0]) : [];
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
return (
<>
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
<Toast
closeLabel={intl.formatMessage(genericMessages.close)}
onClose={() => setGoalToastHeader('')}
show={!!(goalToastHeader)}
>
{goalToastHeader}
</Toast>
<div className="row w-100 mx-0 my-3 justify-content-between">
<div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
{resumeCourseUrl && (
<div className="col-12 col-sm-auto p-0">
<Button variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
</div>
)}
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
@@ -135,6 +150,7 @@ function OutlineTab({ intl }) {
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...accessExpirationAlertMasquerade,
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
@@ -148,18 +164,26 @@ function OutlineTab({ intl }) {
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
</>
)}
<StartOrResumeCourseCard />
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<CourseGoalCard
courseId={courseId}
goalOptions={goalOptions}
title={title}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<WelcomeMessage courseId={courseId} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-md-auto p-0">
<div className="col-12 col-sm-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button>
</div>
</div>
<ol id="courseHome-outline" className="list-unstyled">
<ol className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
@@ -175,16 +199,22 @@ function OutlineTab({ intl }) {
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<ProctoringInfoPanel />
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
disabled to avoid components bouncing around too much as screen is rendered */ }
{(!enableProctoredExams || proctoringPanelStatus === 'loaded') && weeklyLearningGoalEnabled && (
<WeeklyLearningGoalCard
daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null}
subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false}
<ProctoringInfoPanel
courseId={courseId}
username={username}
/>
{courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<CourseTools />
<CourseTools
courseId={courseId}
/>
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
@@ -194,20 +224,21 @@ function OutlineTab({ intl }) {
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
shouldDisplayBorder
/>
)}
<CourseDates
courseId={courseId}
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CourseHandouts />
<CourseHandouts
courseId={courseId}
/>
</div>
)}
</div>

View File

@@ -1,6 +1,3 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
@@ -9,7 +6,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
import userEvent from '@testing-library/user-event';
import messages from './messages';
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
import {
@@ -20,7 +16,6 @@ import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -28,21 +23,20 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata');
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
@@ -63,7 +57,6 @@ describe('Outline Tab', () => {
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'created',
@@ -77,7 +70,7 @@ describe('Outline Tab', () => {
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
});
it('displays link to resume course', async () => {
@@ -332,145 +325,89 @@ describe('Outline Tab', () => {
});
});
describe('Start or Resume Course Card', () => {
it('renders startOrResumeCourseCard', async () => {
await fetchAndRender();
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument();
});
});
describe('Weekly Learning Goal', () => {
it('does not post goals while masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
describe('Course Goals', () => {
const goalOptions = [
['certify', 'Earn a certificate'],
['complete', 'Complete the course'],
['explore', 'Explore the course'],
['unsure', 'Not sure yet'],
];
it('does not render goal widgets if no goals available', async () => {
await fetchAndRender();
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
expect(spy).toHaveBeenCalledTimes(0);
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
});
describe('weekly learning goal is not set', () => {
describe('goal is not set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
goal_options: goalOptions,
selected_goal: null,
},
});
await fetchAndRender();
});
it('renders weekly learning goal card', async () => {
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
it('renders goal card', () => {
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
});
it('disables the subscribe button if no goal is set', async () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
it('renders goal selector on goal selection', async () => {
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
fireEvent.click(certifyGoalButton);
const goalSelector = await screen.findByTestId('edit-goal-selector');
expect(goalSelector).toBeInTheDocument();
});
});
describe('goal is set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: { text: 'Earn a certificate', key: 'certify' },
},
});
await fetchAndRender();
});
it.each`
level | days
${'Casual'} | ${1}
${'Regular'} | ${3}
${'Intense'} | ${5}
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it('shows and hides subscribe to reminders additional text', async () => {
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
it('renders edit goal selector', () => {
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
});
it('updates goal on click', async () => {
// Open dropdown
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
await waitFor(() => {
expect(dropdownButtonNode).toBeInTheDocument();
});
fireEvent.click(dropdownButtonNode);
// Select a new goal
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
await waitFor(() => {
expect(unsureButtonNode).toBeInTheDocument();
});
fireEvent.click(unsureButtonNode);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":3,"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
// Click on subscribe to reminders toggle
const subscriptionSwitch = await screen.getByRole('switch', { name: messages.setGoalReminder.defaultMessage });
expect(subscriptionSwitch).toBeInTheDocument();
fireEvent.click(subscriptionSwitch);
await waitFor(() => {
expect(axiosMock.history.post[1].url).toMatch(goalUrl);
expect(axiosMock.history.post[1].data)
.toMatch(`{"course_id":"${courseId}","days_per_week":3,"subscribed_to_reminders":false}`);
});
// verify that the additional info about subscriptions gets hidden
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).not.toBeInTheDocument();
});
});
it('has button for weekly learning goal selected', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
selected_goal: {
subscribed_to_reminders: true,
days_per_week: 3,
},
},
});
await fetchAndRender();
const button = await screen.queryByTestId('weekly-learning-goal-input-Regular');
expect(button).toBeInTheDocument();
expect(button).toHaveClass('flag-button-selected');
});
it('renders weekly learning goal card if ProctoringInfoPanel is not shown', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is not enabled', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
enableProctoredExams: false,
},
});
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is enabled', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
enableProctoredExams: true,
},
});
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
});
describe('Course Handouts', () => {
@@ -500,6 +437,35 @@ describe('Outline Tab', () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument();
});
it('analytics sent when upgrade link clicked', async () => {
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.tool.accessed', {
org_key: 'edX',
courserun_key: courseId,
course_id: courseId,
is_staff: false,
tool_name: 'edx.tool.verified_upgrade',
});
});
});
describe('Alert List', () => {
@@ -519,8 +485,8 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert');
expect(alert).toHaveAttribute('role', 'alert');
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
@@ -529,8 +495,8 @@ describe('Outline Tab', () => {
it('displays alert for unenrolled user', async () => {
await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert');
expect(alert).toHaveAttribute('role', 'alert');
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
});
@@ -555,35 +521,32 @@ describe('Outline Tab', () => {
});
describe('Access Expiration Alert', () => {
it('renders page banner on masquerade', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
it('has special masquerade text', async () => {
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
},
});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
await fetchAndRender();
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
expect(check).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
it('does not have special masquerade text', async () => {
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
},
});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
await fetchAndRender();
const check = await screen.queryByText('This learner does not have access to this course.', { exact: false });
expect(check).not.toBeInTheDocument();
});
});
@@ -592,7 +555,16 @@ describe('Outline Tab', () => {
it('appears several days out', async () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true, start: '2999-01-01T00:00:00Z' });
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
@@ -601,7 +573,16 @@ describe('Outline Tab', () => {
it('appears today', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true, start: startDate });
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
@@ -676,7 +657,7 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
});
it('renders verification alert', async () => {
const now = new Date();
@@ -710,41 +691,7 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
});
it('renders non passing grade', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {},
user_has_passing_grade: false,
has_ended: true,
enrollment_mode: 'verified',
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
screen.getAllByText('You are not yet eligible for a certificate');
expect(screen.queryByText('You are not yet eligible for a certificate')).toBeInTheDocument();
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
});
it('tracks request cert button', async () => {
sendTrackEvent.mockClear();
@@ -785,7 +732,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
courserun_key: courseId,
courserun_key: 'course-v1:edX+Test+run',
is_staff: false,
org_key: 'edX',
});
@@ -829,7 +776,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
{
courserun_key: courseId,
courserun_key: 'course-v1:edX+Test+run',
is_staff: false,
org_key: 'edX',
});
@@ -873,7 +820,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: courseId,
courserun_key: 'course-v1:edX+Test+run',
is_staff: false,
org_key: 'edX',
});
@@ -916,7 +863,7 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
});
});
@@ -1116,7 +1063,6 @@ describe('Outline Tab', () => {
it('does not appear for 404', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
});
@@ -1230,7 +1176,7 @@ describe('Outline Tab', () => {
});
});
describe('Account Activation Alert', () => {
describe('Accont Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({
observe: () => null,
@@ -1258,7 +1204,7 @@ describe('Outline Tab', () => {
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
});
it('sends account activation email on clicking the re-send email in account activation alert', async () => {
it('sends account activation email on clicking the resened email in account activation alert', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });

View File

@@ -6,6 +6,7 @@ import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/f
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import EffortEstimate from '../../shared/effort-estimate';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
@@ -66,6 +67,7 @@ function Section({
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
<EffortEstimate className="ml-3 align-middle" block={section} />
</div>
</div>
);

View File

@@ -33,7 +33,9 @@ function SequenceLink({
title,
} = sequence;
const {
userTimezone,
datesWidget: {
userTimezone,
},
} = useModel('outline', courseId);
const {
canLoadCourseware,

View File

@@ -36,8 +36,6 @@ function CertificateStatusAlert({ intl, payload }) {
isWebCert,
userTimezone,
org,
notPassingCourseEnded,
tabs,
} = payload;
// eslint-disable-next-line react/prop-types
@@ -66,8 +64,8 @@ function CertificateStatusAlert({ intl, payload }) {
alertProps.body = (
<p>
<FormattedMessage
id="learning.outline.alert.cert.earnedNotAvailable"
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are
id="learning.outline.alert.cert.when"
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
scheduled to be available after {certificateAvailableDate}."
values={{
courseEndDateFormatted,
@@ -120,24 +118,6 @@ function CertificateStatusAlert({ intl, payload }) {
return alertProps;
};
const renderNotPassingCourseEnded = () => {
const progressTab = tabs.find(tab => tab.slug === 'progress');
const progressLink = progressTab && progressTab.url;
const alertProps = {
header: intl.formatMessage(certMessages.certStatusNotPassingHeader),
buttonMessage: intl.formatMessage(certMessages.certStatusNotPassingButton),
body: intl.formatMessage(certStatusMessages.notPassingBody),
buttonVisible: true,
buttonLink: progressLink,
buttonAction: () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_view_grades_button.clicked');
},
};
return alertProps;
};
let alertProps = {};
switch (certStatus) {
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
@@ -149,9 +129,6 @@ function CertificateStatusAlert({ intl, payload }) {
alertProps = renderNotIDVerifiedStatus();
break;
default:
if (notPassingCourseEnded) {
alertProps = renderNotPassingCourseEnded();
}
break;
}
@@ -207,12 +184,6 @@ CertificateStatusAlert.propTypes = {
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string,
org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool,
tabs: PropTypes.arrayOf(PropTypes.shape({
tab_id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string,
})),
}).isRequired,
};

View File

@@ -21,30 +21,17 @@ function verifyCertStatusType(status) {
}
function useCertificateStatusAlert(courseId) {
const VERIFIED_MODES = {
PROFESSIONAL: 'professional',
VERIFIED: 'verified',
NO_ID_PROFESSIONAL_MODE: 'no-id-professional',
CREDIT_MODE: 'credit',
MASTERS: 'masters',
EXECUTIVE_EDUCATION: 'executive-education',
};
const {
isEnrolled,
org,
tabs,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
certData,
hasEnded,
userHasPassingGrade,
userTimezone,
enrollmentMode,
} = useModel('outline', courseId);
const {
@@ -55,11 +42,7 @@ function useCertificateStatusAlert(courseId) {
} = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
);
let certURL = '';
if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
@@ -69,17 +52,8 @@ function useCertificateStatusAlert(courseId) {
}
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
// Only show if:
// - there is a known cert status that we want provide status on.
// - Or the course has ended and the learner does not have a passing grade.
// Only show if there is a known cert status that we want provide status on.
const isVisible = isEnrolled && hasAlertingCertStatus;
const notPassingCourseEnded = (
isEnrolled
&& isVerifiedEnrollmentMode
&& !hasAlertingCertStatus
&& hasEnded
&& !userHasPassingGrade
);
const payload = {
certificateAvailableDate,
certURL,
@@ -89,11 +63,9 @@ function useCertificateStatusAlert(courseId) {
userTimezone,
isWebCert,
org,
notPassingCourseEnded,
tabs,
};
useAlert(isVisible || notPassingCourseEnded, {
useAlert(isVisible, {
code: 'clientCertificateStatusAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',

View File

@@ -2,8 +2,8 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
certStatusEarnedNotAvailableHeader: {
id: 'cert.alert.earned.unavailable.header.v2',
defaultMessage: 'Your grade and certificate status will be available soon.',
id: 'cert.alert.earned.unavailable.header',
defaultMessage: 'Your grade and certificate will be ready soon!',
description: 'Header alerting the user that their certificate will be available soon.',
},
certStatusDownloadableHeader: {
@@ -11,14 +11,6 @@ const messages = defineMessages({
defaultMessage: 'Congratulations! Your certificate is ready.',
description: 'Header alerting the user that their certificate is ready.',
},
certStatusNotPassingHeader: {
id: 'cert.alert.notPassing.header',
defaultMessage: 'You are not yet eligible for a certificate',
},
certStatusNotPassingButton: {
id: 'cert.alert.notPassing.button',
defaultMessage: 'View grades',
},
});
export default messages;

View File

@@ -6,8 +6,8 @@ import {
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
@@ -78,7 +78,7 @@ function CourseEndAlert({ payload }) {
}
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<strong>{msg}</strong><br />
{description}
</Alert>

View File

@@ -15,8 +15,8 @@ export function useCourseEndAlert(courseId) {
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
userTimezone,
} = useModel('outline', courseId);
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');

View File

@@ -6,22 +6,16 @@ import {
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
courseId,
} = payload;
const {
start: startDate,
startDate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
@@ -36,7 +30,7 @@ function CourseStartAlert({ payload }) {
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.outline.alert.start.short"
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
@@ -61,7 +55,7 @@ function CourseStartAlert({ payload }) {
}
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
@@ -86,7 +80,6 @@ function CourseStartAlert({ payload }) {
<FormattedMessage
id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/>
</Alert>
);
@@ -94,7 +87,8 @@ function CourseStartAlert({ payload }) {
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string,
startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};

View File

@@ -0,0 +1,37 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
const isVisible = isEnrolled && startBlock && delta > 0;
const payload = {
startDate: startBlock && startBlock.date,
userTimezone,
};
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCourseStartAlert: CourseStartAlert,
};
}
export default useCourseStartAlert;

View File

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

View File

@@ -3,15 +3,15 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Button, Hyperlink } from '@edx/paragon';
import { Button, Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { Alert } from '../../../../generic/user-messages';
import enrollmentMessages from '../../../../alerts/enrollment-alert/messages';
import genericMessages from '../../../../generic/messages';
import messages from './messages';
import outlineMessages from '../../messages';
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
import { useEnrollClickHandler } from '../../../../alerts/enrollment-alert/hooks';
import { useModel } from '../../../../generic/model-store';
function PrivateCourseAlert({ intl, payload }) {
@@ -32,13 +32,12 @@ function PrivateCourseAlert({ intl, payload }) {
intl.formatMessage(enrollmentMessages.success),
);
const enrollNowButton = (
const enrollNow = (
<Button
disabled={loading}
variant="link"
className="p-0 border-0 align-top mr-1"
className="p-0 border-0 align-top"
style={{ textDecoration: 'underline' }}
size="sm"
onClick={enrollClickHandler}
>
{intl.formatMessage(enrollmentMessages.enrollNowInline)}
@@ -64,7 +63,7 @@ function PrivateCourseAlert({ intl, payload }) {
);
return (
<Alert variant="light" data-testid="private-course-alert">
<Alert type="welcome">
{anonymousUser && (
<>
<p className="font-weight-bold">
@@ -85,11 +84,15 @@ function PrivateCourseAlert({ intl, payload }) {
<>
<p className="font-weight-bold">{intl.formatMessage(outlineMessages.welcomeTo)} {title}</p>
{canEnroll && (
<div className="d-flex">
{enrollNowButton}
{intl.formatMessage(messages.toAccess)}
<>
<FormattedMessage
id="learning.privateCourse.canEnroll"
description="Prompts the user to enroll in the course to see course content."
defaultMessage="{enrollNow} to access the full course."
values={{ enrollNow }}
/>
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
</div>
</>
)}
{!canEnroll && (
<>

View File

@@ -1,11 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
toAccess: {
enroll: {
id: 'alert.enroll',
defaultMessage: ' to access the full course.',
description: 'Text instructing the learner to enroll in the course in order to see course content. The full string'
+ 'would say "Enroll now to access the full course", where "Enroll now" is a button.',
defaultMessage: 'You must be enrolled in the course to see course content.',
description: 'Text instructing the learner to enroll in the course in order to see course content.',
},
});

View File

@@ -4,22 +4,6 @@ const messages = defineMessages({
allDates: {
id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates',
description: 'Text anchor for link that redirects to dates or course timeline tab',
},
casualGoalButtonText: {
id: 'learning.outline.goalButton.casual.text',
defaultMessage: '1 day a week',
description: 'Text shown for casual goal button',
},
casualGoalButtonTitle: {
id: 'learning.outline.goalButton.screenReader.text',
defaultMessage: 'Casual',
description: 'A very short description of the least intense of three learning goals',
},
certAlt: {
id: 'learning.outline.certificateAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
collapseAll: {
id: 'learning.outline.collapseAll',
@@ -39,7 +23,6 @@ const messages = defineMessages({
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Important dates',
description: 'Headline for the (summary of dates) section of the outline page',
},
editGoal: {
id: 'learning.outline.editGoal',
@@ -56,11 +39,6 @@ const messages = defineMessages({
defaultMessage: 'Goal',
description: 'Label for the selected course goal',
},
goalReminderDetail: {
id: 'learning.outline.goalReminderDetail',
defaultMessage: 'If we notice youre not quite at your goal, well send you an email reminder.',
description: 'It describe to learner what is goal reminder service',
},
goalUnsure: {
id: 'learning.outline.goalUnsure',
defaultMessage: 'Not sure yet',
@@ -68,7 +46,6 @@ const messages = defineMessages({
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
description: 'Header for (Course Handouts) section in course outline',
},
incompleteAssignment: {
id: 'learning.outline.incompleteAssignment',
@@ -80,16 +57,6 @@ const messages = defineMessages({
defaultMessage: 'Incomplete section',
description: 'Text used to describe the gray checkmark icon in front of a section title',
},
intenseGoalButtonText: {
id: 'learning.outline.goalButton.intense.text',
defaultMessage: '5 days a week',
description: 'Text shown for intense goal button',
},
intenseGoalButtonTitle: {
id: 'learning.outline.goalButton.intense.title',
defaultMessage: 'Intense',
description: 'A very short description of the most intensive option of three learning goals, Casual, Regular and Intense',
},
learnMore: {
id: 'learning.outline.learnMore',
defaultMessage: 'Learn More',
@@ -99,80 +66,34 @@ const messages = defineMessages({
defaultMessage: 'Open',
description: 'A button to open the given section of the course outline',
},
proctoringInfoPanel: {
id: 'learning.proctoringPanel.header',
defaultMessage: 'This course contains proctored exams',
description: 'Used as a label to indicate that course has proctored exams',
},
regularGoalButtonText: {
id: 'learning.outline.goalButton.regular.text',
defaultMessage: '3 days a week',
description: 'Text shown for regular goal button',
},
regularGoalButtonTitle: {
id: 'learning.outline.goalButton.regular.title',
defaultMessage: 'Regular',
description: 'A very short description of the middle option of three learning goals, Casual, Regular and Intense',
},
resumeBlurb: {
id: 'learning.outline.resumeBlurb',
defaultMessage: 'Pick up where you left off',
description: 'Text describing to the learner that they can return to the last section they visited within the course.',
},
resume: {
id: 'learning.outline.resume',
defaultMessage: 'Resume course',
description: 'Anchor text for button that would resume course',
},
setGoal: {
id: 'learning.outline.setGoal',
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
description: 'In indicate to learner how to set or use the goal reminder service',
},
setGoalReminder: {
id: 'learning.outline.setGoalReminder',
defaultMessage: 'Set a goal reminder',
description: 'The text for the radio button which activate or deactivate the goal reminder service',
},
setLearningGoalButtonScreenReaderText: {
id: 'learning.outline.goalButton.casual.title',
defaultMessage: 'Set a learning goal style.',
description: 'screen reader text informing learner they can select an intensity of learning goal',
},
setWeeklyGoal: {
id: 'learning.outline.setWeeklyGoal',
defaultMessage: 'Set a weekly learning goal',
description: 'The headline for (goal reminder service) section in course outline',
},
setWeeklyGoalDetail: {
id: 'learning.outline.setWeeklyGoalDetail',
defaultMessage: 'Setting a goal motivates you to finish the course. You can always change it later.',
description: 'It indiacate the gaol or the purpose of the goal reminder service to learners',
},
start: {
id: 'learning.outline.start',
defaultMessage: 'Start course',
description: 'The text for button which starts the course',
},
startBlurb: {
id: 'learning.outline.startBlurb',
defaultMessage: 'Begin your course today',
defaultMessage: 'Start Course',
},
tools: {
id: 'learning.outline.tools',
defaultMessage: 'Course Tools',
description: 'Headline for the (course tools) section in course outline. course tool might include links to course bookmarks, financial assistance...etc',
},
upgradeButton: {
id: 'learning.outline.upgradeButton',
defaultMessage: 'Upgrade ({symbol}{price})',
description: 'Text for the button which redirects to the upgrading page',
},
upgradeTitle: {
id: 'learning.outline.upgradeTitle',
defaultMessage: 'Pursue a verified certificate',
description: 'Upgrade title',
},
certAlt: {
id: 'learning.outline.certificateAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
welcomeMessage: {
id: 'learning.outline.welcomeMessage',
@@ -191,135 +112,109 @@ const messages = defineMessages({
defaultMessage: 'Welcome to',
description: 'This precedes the title of the course',
},
proctoringInfoPanel: {
id: 'learning.proctoringPanel.header',
defaultMessage: 'This course contains proctored exams',
},
notStartedProctoringStatus: {
id: 'learning.proctoringPanel.status.notStarted',
defaultMessage: 'Not Started',
description: 'It indcate that proctortrack onboarding exam hasnt started yet',
},
startedProctoringStatus: {
id: 'learning.proctoringPanel.status.started',
defaultMessage: 'Started',
description: 'Label to indicate the starting status of the proctortrack onboarding exam',
},
submittedProctoringStatus: {
id: 'learning.proctoringPanel.status.submitted',
defaultMessage: 'Submitted',
description: 'Label to indicate the submitted status of proctortrack onboarding exam',
},
verifiedProctoringStatus: {
id: 'learning.proctoringPanel.status.verified',
defaultMessage: 'Verified',
description: 'Label to indicate the verified status of the proctortrack onboarding exam',
},
rejectedProctoringStatus: {
id: 'learning.proctoringPanel.status.rejected',
defaultMessage: 'Rejected',
description: 'Label to indicate the rejection status of the proctortrack onboarding exam',
},
errorProctoringStatus: {
id: 'learning.proctoringPanel.status.error',
defaultMessage: 'Error',
description: 'Label to indicate that there is error in proctortrack onboarding exam',
},
otherCourseApprovedProctoringStatus: {
id: 'learning.proctoringPanel.status.otherCourseApproved',
defaultMessage: 'Approved in Another Course',
description: 'Label to indicate that the proctortrack onboarding exam is verified based on taking onboarding exam on another course',
},
expiringSoonProctoringStatus: {
id: 'learning.proctoringPanel.status.expiringSoon',
defaultMessage: 'Expiring Soon',
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
},
proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:',
description: 'The text that precede the status label of proctortrack onboarding exam',
},
notStartedProctoringMessage: {
id: 'learning.proctoringPanel.message.notStarted',
defaultMessage: 'You have not started your onboarding exam.',
description: 'The text that explain the meaning of (not started) label of the proctortrack onboarding exam',
},
startedProctoringMessage: {
id: 'learning.proctoringPanel.message.started',
defaultMessage: 'You have started your onboarding exam.',
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam',
},
submittedProctoringMessage: {
id: 'learning.proctoringPanel.message.submitted',
defaultMessage: 'You have submitted your onboarding exam.',
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam',
},
verifiedProctoringMessage: {
id: 'learning.proctoringPanel.message.verified',
defaultMessage: 'Your onboarding exam has been approved in this course.',
description: 'The text that explain the meaning of (verified) label of the proctortrack onboarding exam',
},
rejectedProctoringMessage: {
id: 'learning.proctoringPanel.message.rejected',
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
description: 'The text that explain the meaning of (rejected) label of the proctortrack onboarding exam',
},
errorProctoringMessage: {
id: 'learning.proctoringPanel.message.error',
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
description: 'The text that explain the meaning of (error) label of the proctortrack onboarding exam',
},
otherCourseApprovedProctoringMessage: {
id: 'learning.proctoringPanel.message.otherCourseApproved',
defaultMessage: 'Your onboarding exam has been approved in another course.',
description: 'The text that explain the meaning of (approved in another course) label of the proctortrack onboarding exam',
},
otherCourseApprovedProctoringDetail: {
id: 'learning.proctoringPanel.detail.otherCourseApproved',
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (approved in another course)',
},
expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
},
proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
description: 'It indicate key and important fact to learner about the importance of taking proctortrack onboarding exam',
},
proctoringPanelGeneralInfoSubmitted: {
id: 'learning.proctoringPanel.generalInfoSubmitted',
defaultMessage: 'Your submitted profile is in review.',
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam',
},
proctoringPanelGeneralTime: {
id: 'learning.proctoringPanel.generalTime',
defaultMessage: 'Onboarding profile review can take 2+ business days.',
description: 'This text explain for how long the (in review) status of the proctortrack onboarding exam might remain',
},
proctoringOnboardingButton: {
id: 'learning.proctoringPanel.onboardingButton',
defaultMessage: 'Complete Onboarding',
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released',
},
proctoringOnboardingPracticeButton: {
id: 'learning.proctoringPanel.onboardingPracticeButton',
defaultMessage: 'View Onboarding Exam',
description: 'The text that appears on onboarding exam while its not released, so learners can take or view it as a practice',
},
proctoringOnboardingButtonNotOpen: {
id: 'learning.proctoringPanel.onboardingButtonNotOpen',
defaultMessage: 'Onboarding Opens: {releaseDate}',
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam',
},
proctoringReviewRequirementsButton: {
id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements',
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctortrack onboarding exam',
},
proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due',
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
},
});

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -9,20 +8,16 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseDates({
courseId,
intl,
/** [MM-P2P] Experiment */
mmp2p,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
datesTabLink,
userTimezone,
},
} = useModel('outline', courseId);
@@ -32,34 +27,34 @@ function CourseDates({
return (
<section className="mb-4">
<div id="courseHome-dates">
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
<ol className="list-unstyled">
{courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
))}
</ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</div>
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
<ol className="list-unstyled">
{courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
))}
</ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</section>
);
}
CourseDates.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({}),
};
CourseDates.defaultProps = {
courseId: null,
/** [MM-P2P] Experiment */
mmp2p: {},
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Card } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { saveCourseGoal } from '../../data';
function CourseGoalCard({
courseId,
goalOptions,
intl,
title,
setGoalToDisplay,
setGoalToastHeader,
}) {
function selectGoalHandler(event) {
const selectedGoal = {
key: event.currentTarget.getAttribute('data-goal-key'),
text: event.currentTarget.getAttribute('data-goal-text'),
};
saveCourseGoal(courseId, selectedGoal.key).then((response) => {
const { data } = response;
const {
header,
} = data;
setGoalToDisplay(selectedGoal);
setGoalToastHeader(header);
});
}
return (
<Card className="mb-3" data-testid="course-goal-card">
<Card.Body>
<div className="row w-100 m-0 justify-content-between align-items-center">
<div className="col col-8 p-0">
<h2 className="h4 m-0">{intl.formatMessage(messages.welcomeTo)} {title}</h2>
</div>
<div className="col col-auto p-0">
<Button
variant="link"
className="p-0"
size="sm"
block
data-goal-key="unsure"
data-goal-text={`${intl.formatMessage(messages.goalUnsure)}`}
onClick={(event) => { selectGoalHandler(event); }}
>
{intl.formatMessage(messages.goalUnsure)}
</Button>
</div>
</div>
<Card.Text className="my-2 mx-1 text-dark-500">{intl.formatMessage(messages.setGoal)}</Card.Text>
<div className="row w-100 m-0">
{goalOptions.map((goal) => {
const [goalKey, goalText] = goal;
return (
(goalKey !== 'unsure') && (
<div key={`goal-${goalKey}`} className="col-auto flex-grow-1 mx-1 my-2 p-0">
<Button
variant="outline-primary"
block
data-goal-key={goalKey}
data-goal-text={goalText}
onClick={(event) => { selectGoalHandler(event); }}
>
{goalText}
</Button>
</div>
)
);
})}
</div>
</Card.Body>
</Card>
);
}
CourseGoalCard.propTypes = {
courseId: PropTypes.string.isRequired,
goalOptions: PropTypes.arrayOf(
PropTypes.arrayOf(PropTypes.string),
).isRequired,
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
setGoalToDisplay: PropTypes.func.isRequired,
setGoalToastHeader: PropTypes.func.isRequired,
};
export default injectIntl(CourseGoalCard);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -7,10 +7,7 @@ import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseHandouts({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
function CourseHandouts({ courseId, intl }) {
const {
handoutsHtml,
} = useModel('outline', courseId);
@@ -32,6 +29,7 @@ function CourseHandouts({ intl }) {
}
CourseHandouts.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -12,12 +12,8 @@ import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
function CourseTools({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
function CourseTools({ courseId, intl }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
courseTools,
@@ -40,6 +36,16 @@ function CourseTools({ intl }) {
is_staff: administrator,
tool_name: analyticsId,
});
if (analyticsId === 'edx.tool.verified_upgrade') {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
}
};
const renderIcon = (iconClasses) => {
@@ -73,16 +79,18 @@ function CourseTools({ intl }) {
</a>
</li>
))}
<li className="small" id="courseHome-launchTourLink">
<LaunchCourseHomeTourButton />
</li>
</ul>
</section>
);
}
CourseTools.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired,
};
CourseTools.defaultProps = {
courseId: null,
};
export default injectIntl(CourseTools);

View File

@@ -1,43 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
function FlagButton({
buttonIcon,
title,
text,
handleSelect,
isSelected,
}) {
return (
<button
type="button"
className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5',
isSelected ? 'flag-button-selected' : '')}
aria-checked={isSelected}
role="radio"
onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
>
<div className="row w-100 m-0 justify-content-center pb-1">
{buttonIcon}
</div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
{title}
</div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
{text}
</div>
</button>
);
}
FlagButton.propTypes = {
buttonIcon: PropTypes.element.isRequired,
title: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
handleSelect: PropTypes.func.isRequired,
isSelected: PropTypes.bool.isRequired,
};
export default FlagButton;

View File

@@ -1,37 +0,0 @@
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
.flag-button {
background-color: $white;
border: 1px solid $light-400;
border-radius: .2rem;
box-shadow: 0 0 0 2px $light-400;
&:hover {
border: 1px solid $primary-300;
box-shadow: 0 0 0 2px $white;
}
}
.flag-button-selected {
border: 1px solid $primary-300;
box-shadow: 0 0 0 2px $primary-300;
pointer-events: none;
}
// @see https://heydonworks.com/article/the-flexbox-holy-albatross-reincarnated/
// use the container size for layout instead of device media query
.flag-button-container {
display: flex;
flex-wrap: wrap;
--margin: 1rem;
--modifier: calc(20rem - 100%);
margin: calc(var(--margin) * -1);
}
.flag-button-container > * {
flex-grow: 1;
flex-basis: calc(var(--modifier) * 999);
margin: var(--margin);
}

View File

@@ -1,59 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
// These flag svgs are derivatives of the Flag icon from paragon
import { ReactComponent as FlagIntenseIcon } from './flag_black.svg';
import { ReactComponent as FlagCasualIcon } from './flag_outline.svg';
import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
import FlagButton from './FlagButton';
import messages from '../messages';
function LearningGoalButton({
level,
isSelected,
handleSelect,
intl,
}) {
const buttonDetails = {
casual: {
daysPerWeek: 1,
title: messages.casualGoalButtonTitle,
text: messages.casualGoalButtonText,
icon: <FlagCasualIcon />,
},
regular: {
daysPerWeek: 3,
title: messages.regularGoalButtonTitle,
text: messages.regularGoalButtonText,
icon: <FlagRegularIcon />,
},
intense: {
daysPerWeek: 5,
title: messages.intenseGoalButtonTitle,
text: messages.intenseGoalButtonText,
icon: <FlagIntenseIcon />,
},
};
const values = buttonDetails[level];
return (
<FlagButton
buttonIcon={values.icon}
title={intl.formatMessage(values.title)}
text={intl.formatMessage(values.text)}
handleSelect={() => handleSelect(values.daysPerWeek)}
isSelected={isSelected}
/>
);
}
LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
handleSelect: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(LearningGoalButton);

View File

@@ -1,30 +1,19 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
function ProctoringInfoPanel({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
username,
} = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const [link, setLink] = useState('');
const [onboardingPastDue, setOnboardingPastDue] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
function ProctoringInfoPanel({ courseId, username, intl }) {
const [status, setStatus] = useState('');
const [readableStatus, setReadableStatus] = useState('');
const [link, setLink] = useState('');
const [releaseDate, setReleaseDate] = useState(null);
const [readableStatus, setReadableStatus] = useState('');
const readableStatuses = {
notStarted: 'notStarted',
@@ -89,10 +78,6 @@ function ProctoringInfoPanel({ intl }) {
.then(
response => {
if (response) {
if (Object.keys(response).length > 0) {
setShowInfoPanel(true);
}
setStatus(response.onboarding_status);
setLink(response.onboarding_link);
const expirationDate = response.expiration_date;
@@ -102,60 +87,14 @@ function ProctoringInfoPanel({ intl }) {
setReadableStatus(getReadableStatusClass(response.onboarding_status));
}
setReleaseDate(new Date(response.onboarding_release_date));
setOnboardingPastDue(response.onboarding_past_due);
}
},
)
.catch(() => {
/* Do nothing. API throws 404 when class does not have proctoring */
})
.finally(() => {
dispatch(fetchProctoringInfoResolved());
});
);
}, []);
let onboardingExamButton = null;
if (isNotYetReleased(releaseDate)) {
onboardingExamButton = (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(
messages.proctoringOnboardingButtonNotOpen,
{
releaseDate: intl.formatDate(releaseDate, {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
)}
</Button>
);
} else if (onboardingPastDue) {
onboardingExamButton = (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(messages.proctoringOnboardingButtonPastDue)}
</Button>
);
} else if (!isNotYetReleased(releaseDate)) {
if (readableStatus === readableStatuses.otherCourseApproved) {
onboardingExamButton = (
<Button variant="primary" block href={link}>
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
</Button>
);
} else if (readableStatus !== readableStatuses.otherCourseApproved) {
onboardingExamButton = (
<Button variant="primary" block href={link}>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</Button>
);
}
}
return (
<>
{ showInfoPanel && (
{ link && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.proctoringInfoPanel)}</h2>
<div>
@@ -176,17 +115,50 @@ function ProctoringInfoPanel({ intl }) {
<>
<p>
{isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfo)
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfo)}
</>
)}
{!isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)}
</>
)}
</p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</>
)}
{isNotYetSubmitted(status) && (
onboardingExamButton
<>
{!isNotYetReleased(releaseDate) && (
<Button variant="primary" block href={`${getConfig().LMS_BASE_URL}${link}`}>
{readableStatus === readableStatuses.otherCourseApproved && (
<>
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
</>
)}
{readableStatus !== readableStatuses.otherCourseApproved && (
<>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</>
)}
</Button>
)}
{isNotYetReleased(releaseDate) && (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(
messages.proctoringOnboardingButtonNotOpen,
{
releaseDate: intl.formatDate(releaseDate, {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
)}
</Button>
)}
</>
)}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
{intl.formatMessage(messages.proctoringReviewRequirementsButton)}
@@ -199,7 +171,13 @@ function ProctoringInfoPanel({ intl }) {
}
ProctoringInfoPanel.propTypes = {
courseId: PropTypes.string.isRequired,
username: PropTypes.string,
intl: intlShape.isRequired,
};
ProctoringInfoPanel.defaultProps = {
username: null,
};
export default injectIntl(ProctoringInfoPanel);

View File

@@ -1,68 +0,0 @@
import React from 'react';
import { Button, Card } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function StartOrResumeCourseCard({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
} = useModel('courseHomeMeta', courseId);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const {
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
} = useModel('outline', courseId);
if (!resumeCourseUrl) {
return null;
}
const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
...eventProperties,
event_type: hasVisitedCourse ? 'resume' : 'start',
url: resumeCourseUrl,
});
};
return (
<Card className="mb-3 raised-card" data-testid="start-resume-card">
<Card.Header
title={hasVisitedCourse ? intl.formatMessage(messages.resumeBlurb) : intl.formatMessage(messages.startBlurb)}
actions={(
<Button
variant="brand"
block
href={resumeCourseUrl}
onClick={() => logResumeCourseClick()}
>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
)}
/>
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
<Card.Footer><></></Card.Footer>
</Card>
);
}
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StartOrResumeCourseCard);

View File

@@ -0,0 +1,85 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from '../messages';
import { saveCourseGoal } from '../../data';
function UpdateGoalSelector({
courseId,
goalOptions,
intl,
selectedGoal,
setGoalToDisplay,
setGoalToastHeader,
}) {
function selectGoalHandler(event) {
const key = event.currentTarget.id;
const text = event.currentTarget.innerText;
const newGoal = {
key,
text,
};
setGoalToDisplay(newGoal);
saveCourseGoal(courseId, key).then((response) => {
const { data } = response;
const {
header,
} = data;
setGoalToastHeader(header);
});
}
return (
<>
<section className="mb-4">
<div className="row w-100 m-0">
<div className="col-12 p-0">
<label className="h4 m-0" htmlFor="edit-goal-selector">
{intl.formatMessage(messages.goal)}
</label>
</div>
<div className="col-12 p-0">
<Dropdown className="py-2">
<Dropdown.Toggle variant="outline-primary" block id="edit-goal-selector" data-testid="edit-goal-selector">
{selectedGoal.text}
</Dropdown.Toggle>
<Dropdown.Menu>
{goalOptions.map(([goalKey, goalText]) => (
<Dropdown.Item
id={goalKey}
key={goalKey}
onClick={(event) => { selectGoalHandler(event); }}
role="button"
>
{goalText}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
</div>
</section>
</>
);
}
UpdateGoalSelector.propTypes = {
courseId: PropTypes.string.isRequired,
goalOptions: PropTypes.arrayOf(
PropTypes.arrayOf(PropTypes.string),
).isRequired,
intl: intlShape.isRequired,
selectedGoal: PropTypes.shape({
key: PropTypes.string,
text: PropTypes.string,
}).isRequired,
setGoalToDisplay: PropTypes.func.isRequired,
setGoalToastHeader: PropTypes.func.isRequired,
};
export default injectIntl(UpdateGoalSelector);

View File

@@ -1,140 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@edx/paragon';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Email } from '@edx/paragon/icons';
import { useSelector } from 'react-redux';
import messages from '../messages';
import LearningGoalButton from './LearningGoalButton';
import { saveWeeklyLearningGoal } from '../../data';
import { useModel } from '../../../generic/model-store';
import './FlagButton.scss';
function WeeklyLearningGoalCard({
daysPerWeek,
subscribedToReminders,
intl,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
isMasquerading,
org,
} = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
function handleSelect(days) {
// Set the subscription button if this is the first time selecting a goal
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
setGetReminderSelected(selectReminders);
setDaysPerWeekGoal(days);
if (!isMasquerading) { // don't save goal updates while masquerading
saveWeeklyLearningGoal(courseId, days, selectReminders);
sendTrackEvent('edx.ui.lms.goal.days-per-week.changed', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
num_days: days,
reminder_selected: selectReminders,
});
}
}
function handleSubscribeToReminders(event) {
const isGetReminderChecked = event.target.checked;
setGetReminderSelected(isGetReminderChecked);
if (!isMasquerading) { // don't save goal updates while masquerading
saveWeeklyLearningGoal(courseId, daysPerWeekGoal, isGetReminderChecked);
sendTrackEvent('edx.ui.lms.goal.reminder-selected.changed', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
num_days: daysPerWeekGoal,
reminder_selected: isGetReminderChecked,
});
}
}
return (
<Card
id="courseHome-weeklyLearningGoal"
className="row w-100 m-0 mb-3 raised-card"
data-testid="weekly-learning-goal-card"
>
<Card.Header
size="sm"
title={(<div id="set-weekly-goal-header">{intl.formatMessage(messages.setWeeklyGoal)}</div>)}
subtitle={intl.formatMessage(messages.setWeeklyGoalDetail)}
/>
<Card.Section className="text-gray-700 small">
<div
role="radiogroup"
aria-labelledby="set-weekly-goal-header"
className="flag-button-container m-0 p-0"
>
<LearningGoalButton
level="casual"
isSelected={daysPerWeekGoal === 1}
handleSelect={handleSelect}
/>
<LearningGoalButton
level="regular"
isSelected={daysPerWeekGoal === 3}
handleSelect={handleSelect}
/>
<LearningGoalButton
level="intense"
isSelected={daysPerWeekGoal === 5}
handleSelect={handleSelect}
/>
</div>
<div className="d-flex pt-3">
<Form.Switch
checked={isGetReminderSelected}
onChange={(event) => handleSubscribeToReminders(event)}
disabled={!daysPerWeekGoal}
>
<small>{intl.formatMessage(messages.setGoalReminder)}</small>
</Form.Switch>
</div>
</Card.Section>
{isGetReminderSelected && (
<Card.Section muted>
<div className="row w-100 m-0 small align-center">
<div className="d-flex align-items-center pr-1">
<Icon
className="text-primary-500"
src={Email}
/>
</div>
<div className="col">
{intl.formatMessage(messages.goalReminderDetail)}
</div>
</div>
</Card.Section>
)}
</Card>
);
}
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,
subscribedToReminders: PropTypes.bool,
intl: intlShape.isRequired,
};
WeeklyLearningGoalCard.defaultProps = {
daysPerWeek: null,
subscribedToReminders: false,
};
export default injectIntl(WeeklyLearningGoalCard);

View File

@@ -2,13 +2,14 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@edx/paragon';
import { Button, TransitionReplace } from '@edx/paragon';
import truncate from 'truncate-html';
import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { Alert } from '../../../generic/user-messages';
import { dismissWelcomeMessage } from '../../data/thunks';
function WelcomeMessage({ courseId, intl }) {
@@ -26,48 +27,52 @@ function WelcomeMessage({ courseId, intl }) {
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
const dispatch = useDispatch();
return (
<Alert
data-testid="alert-container-welcome"
variant="light"
stacked
dismissible
show={display}
onClose={() => {
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
className="raised-card"
actions={messageCanBeShortened ? [
<Button
onClick={() => setShowShortMessage(!showShortMessage)}
variant="outline-primary"
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
</Button>,
] : []}
>
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
display && (
<Alert
type="welcome"
dismissible
onDismiss={() => {
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
footer={messageCanBeShortened && (
<div className="row w-100 m-0">
<div className="col-12 col-sm-auto p-0">
<Button
block
onClick={() => setShowShortMessage(!showShortMessage)}
variant="outline-primary"
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
</Button>
</div>
</div>
)}
</TransitionReplace>
</Alert>
>
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
</Alert>
)
);
}

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4 6L14 4H5V21H7V14H12.6L13 16H20V6H14.4Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 173 B

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="15"
height="17"
viewBox="0 0 15 17"
fill="none"
version="1.1"
id="svg11"
xmlns="http://www.w3.org/2000/svg">
<path
d="M9.4 2L9 0H0V17H2V10H7.6L8 12H15V2H9.4ZM13 10H9.64L9.24 8H2V2H7.36L7.76 4H13V10Z"
fill="#002B2B"
id="path9" />
<path
style="fill:#808080;fill-rule:evenodd;stroke-width:0.0150977"
d="M 9.6594698,9.9871226 C 9.6577909,9.9829707 9.5654776,9.5311723 9.4543296,8.9831261 L 9.2522415,7.9866785 5.6376662,7.9790074 2.0230906,7.9713362 V 4.9970494 2.0227625 l 2.6636151,0.00771 2.6636151,0.00771 0.1968204,0.9888987 0.1968205,0.9888988 h 2.6200263 2.620026 v 2.9893428 2.9893428 h -1.660746 c -0.91341,0 -1.6621194,-0.0034 -1.6637982,-0.00755 z"
id="path302" />
</svg>

Before

Width:  |  Height:  |  Size: 801 B

View File

@@ -1,3 +0,0 @@
<svg width="15" height="17" viewBox="0 0 15 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.4 2L9 0H0V17H2V10H7.6L8 12H15V2H9.4ZM13 10H9.64L9.24 8H2V2H7.36L7.76 4H13V10Z" fill="#002B2B"/>
</svg>

Before

Width:  |  Height:  |  Size: 211 B

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { breakpoints, useWindowSize } from '@edx/paragon';
import React, { useEffect } from 'react';
import { layoutGenerator } from 'react-break';
import { useDispatch, useSelector } from 'react-redux';
import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion';
@@ -10,6 +10,7 @@ import GradeSummary from './grades/grade-summary/GradeSummary';
import ProgressHeader from './ProgressHeader';
import RelatedLinks from './related-links/RelatedLinks';
import { setGradesFeatureStatus } from '../data/slice';
import { useModel } from '../../generic/model-store';
function ProgressTab() {
@@ -18,20 +19,26 @@ function ProgressTab() {
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
completionSummary: {
lockedCount,
},
} = useModel('progress', courseId);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const gradesFeatureIsLocked = lockedCount > 0;
const applyLockedOverlay = gradesFeatureIsLocked ? 'locked-overlay' : '';
const windowWidth = useWindowSize().width;
if (windowWidth === undefined) {
// Bail because we don't want to load <CertificateStatus/> twice, emitting 'visited' events both times.
// This is a hacky solution, since the user can resize the screen and still get two visited events.
// But I'm leaving a larger refactor as an exercise to a future reader.
return null;
}
const dispatch = useDispatch();
useEffect(() => {
dispatch(setGradesFeatureStatus({ gradesFeatureIsLocked }));
}, []);
const wideScreen = windowWidth >= breakpoints.large.minWidth;
const layout = layoutGenerator({
mobile: 0,
desktop: 992,
});
const OnMobile = layout.is('mobile');
const OnDesktop = layout.isAtLeast('desktop');
return (
<>
<ProgressHeader />
@@ -39,9 +46,11 @@ function ProgressTab() {
{/* Main body */}
<div className="col-12 col-md-8 p-0">
<CourseCompletion />
{!wideScreen && <CertificateStatus />}
<OnMobile>
<CertificateStatus />
</OnMobile>
<CourseGrade />
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsLocked}>
<GradeSummary />
<DetailedGrades />
</div>
@@ -49,7 +58,9 @@ function ProgressTab() {
{/* Side panel */}
<div className="col-12 col-md-4 p-0 px-md-4">
{wideScreen && <CertificateStatus />}
<OnDesktop>
<CertificateStatus />
</OnDesktop>
<RelatedLinks />
</div>
</div>

View File

@@ -3,7 +3,6 @@ import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@edx/paragon';
import MockAdapter from 'axios-mock-adapter';
import {
@@ -13,8 +12,6 @@ import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
import messages from './grades/messages';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -22,18 +19,17 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Progress Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/*`);
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata');
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('progressTabData');
const courseId = defaultMetadata.id;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
@@ -53,7 +49,6 @@ describe('Progress Tab', () => {
// Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
logUnhandledRequests(axiosMock);
});
@@ -65,7 +60,6 @@ describe('Progress Tab', () => {
it('sends event on click of dates tab link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const datesTabLink = screen.getByRole('link', { name: 'Dates' });
fireEvent.click(datesTabLink);
@@ -81,7 +75,6 @@ describe('Progress Tab', () => {
it('sends event on click of outline tab link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
@@ -118,7 +111,6 @@ describe('Progress Tab', () => {
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
@@ -133,8 +125,6 @@ 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();
});
@@ -168,8 +158,6 @@ 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();
});
@@ -188,7 +176,6 @@ describe('Progress Tab', () => {
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
@@ -217,8 +204,6 @@ 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();
});
@@ -267,26 +252,6 @@ describe('Progress Tab', () => {
sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade',
},
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',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument();
@@ -294,7 +259,8 @@ describe('Progress Tab', () => {
expect(screen.getAllByRole('link', 'Unlock now')).toHaveLength(3);
});
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
it('sends event on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
sendTrackEvent.mockClear();
setTabData({
completion_summary: {
complete_count: 1,
@@ -309,49 +275,20 @@ describe('Progress Tab', () => {
sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade',
},
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',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
});
await fetchAndRender();
sendTrackEvent.mockClear();
expect(screen.getByText('locked feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
const upgradeButton = screen.getAllByRole('link', 'Unlock now')[0];
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.grades_upgrade.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
});
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'progress_locked',
linkType: 'button',
pageName: 'progress',
});
});
it('renders locked feature preview with no upgrade button when user has locked content but cannot upgrade', async () => {
@@ -361,26 +298,6 @@ describe('Progress Tab', () => {
incomplete_count: 1,
locked_count: 1,
},
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',
learner_has_access: false,
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',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument();
@@ -392,64 +309,9 @@ describe('Progress Tab', () => {
expect(screen.queryByText('locked feature')).not.toBeInTheDocument();
});
it('renders limited feature preview with upgrade button when user has access to some content that would typically be locked', async () => {
setTabData({
completion_summary: {
complete_count: 1,
incomplete_count: 1,
locked_count: 1,
},
verified_mode: {
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade',
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@123456',
display_name: 'First subsection',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
{
assignment_type: 'Exam',
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: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('limited feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to work towards a certificate.')).toBeInTheDocument();
expect(screen.queryAllByText('You have limited access to graded assignments as part of the audit track in this course.')).toHaveLength(2);
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
});
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.
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%.
setTabData({
section_scores: [
{
@@ -459,7 +321,6 @@ describe('Progress Tab', () => {
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
@@ -476,7 +337,6 @@ describe('Progress Tab', () => {
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 1,
@@ -491,31 +351,10 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
expect(screen.getByText('First subsection')).toBeInTheDocument();
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
});
it('renders correct title when credit information is available', async () => {
setTabData({
credit_course_requirements: {
eligibility_status: 'eligible',
requirements: [
{
namespace: 'proctored_exam',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Proctored Mid Term Exam',
criteria: {},
reason: {},
status: 'satisfied',
status_date: '2015-06-26 11:07:42',
order: 1,
},
],
},
});
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
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();
});
});
@@ -664,7 +503,9 @@ describe('Progress Tab', () => {
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
});
it('renders override notice', async () => {
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: [
{
@@ -675,16 +516,12 @@ describe('Progress Tab', () => {
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
has_graded_assignment: true,
learner_has_access: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
problem_scores: [{
earned: 1,
possible: 2,
}],
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
@@ -692,24 +529,14 @@ describe('Progress Tab', () => {
display_name: 'Second section',
subsections: [
{
assignment_type: 'Exam',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@98765',
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 0,
num_points_earned: 1,
num_points_possible: 1,
override: {
system: 'PROCTORING',
reason: 'Suspicious activity',
},
percent_graded: 1.0,
problem_scores: [{
earned: 0,
possible: 1,
}],
show_correctness: 'always',
show_grades: true,
show_grades: false,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
@@ -718,14 +545,7 @@ describe('Progress Tab', () => {
});
await fetchAndRender();
const problemScoreDrawerToggle = screen.getByRole('button', { name: 'Toggle individual problem scores for Second subsection' });
expect(problemScoreDrawerToggle).toBeInTheDocument();
// Open the problem score drawer
fireEvent.click(problemScoreDrawerToggle);
expect(screen.getByText(messages.sectionGradeOverridden.defaultMessage)).toBeInTheDocument();
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
});
});
@@ -734,13 +554,13 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('First subsection'));
expect(screen.getByText('Second subsection'));
expect(screen.getByRole('link', { name: 'First subsection' }));
expect(screen.getByRole('link', { name: 'Second subsection' }));
});
it('sends event on click of subsection link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const subsectionLink = screen.getByRole('link', { name: 'First subsection' });
@@ -756,8 +576,8 @@ describe('Progress Tab', () => {
});
it('sends event on click of course outline link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
@@ -771,20 +591,6 @@ describe('Progress Tab', () => {
});
});
it('renders individual problem score drawer', async () => {
sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const problemScoreDrawerToggle = screen.getByRole('button', { name: 'Toggle individual problem scores for First subsection' });
expect(problemScoreDrawerToggle).toBeInTheDocument();
// Open the problem score drawer
fireEvent.click(problemScoreDrawerToggle);
expect(screen.getByText('Problem Scores:')).toBeInTheDocument();
expect(screen.getAllByText('0/1')).toHaveLength(3);
});
it('render message when section scores are not populated', async () => {
setTabData({
section_scores: [],
@@ -797,7 +603,22 @@ describe('Progress Tab', () => {
describe('Certificate Status', () => {
beforeAll(() => {
global.innerWidth = breakpoints.large.minWidth;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => {
const matches = !!(query === 'screen and (min-width: 992px)');
return {
matches,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
};
}),
});
});
describe('enrolled user', () => {
@@ -1137,21 +958,13 @@ describe('Progress Tab', () => {
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
certificate_status_variant: 'audit_passing',
});
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'progress_certificate',
linkType: 'button',
pageName: 'progress',
});
});
it('Displays nothing if audit only', async () => {
@@ -1222,112 +1035,6 @@ describe('Progress Tab', () => {
});
});
describe('Credit Information', () => {
it('renders credit information when provided', async () => {
setTabData({
credit_course_requirements: {
eligibility_status: 'eligible',
requirements: [
{
namespace: 'proctored_exam',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Proctored Mid Term Exam',
criteria: {},
reason: {},
status: null,
status_date: '2015-06-26 11:07:42',
order: 1,
},
{
namespace: 'grade',
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
display_name: 'Minimum Passing Grade',
criteria: { min_grade: 0.8 },
reason: { final_grade: 0.95 },
status: 'satisfied',
status_date: '2015-06-26 11:07:44',
order: 2,
},
],
},
});
await fetchAndRender();
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
expect(screen.getByText('Requirements for course credit')).toBeInTheDocument();
expect(screen.getByText('You have met the requirements for credit in this course.', { exact: false })).toBeInTheDocument();
expect(screen.getByText('Proctored Mid Term Exam:')).toBeInTheDocument();
// 80% comes from the criteria.minGrade being 0.8
expect(screen.getByText('Minimum grade for credit (80%):')).toBeInTheDocument();
// Completed because the grade requirement has been satisfied
expect(screen.getByText('Completed')).toBeInTheDocument();
});
it('does not render credit information when it is not provided', async () => {
await fetchAndRender();
expect(screen.queryByText('Grades & Credit')).not.toBeInTheDocument();
expect(screen.queryByText('Requirements for course credit.')).not.toBeInTheDocument();
});
});
describe('Access expiration masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
},
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: false,
},
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
});
});
describe('Course start masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({
is_enrolled: true,
original_user_is_staff: true,
is_staff: false,
start: '2999-01-01T00:00:00Z',
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({
is_enrolled: true,
original_user_is_staff: true,
is_staff: true,
start: '2999-01-01T00:00:00Z',
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
});
});
describe('Viewing progress page of other students by changing url', () => {
it('Changing the url changes the header', async () => {
setMetadata({ is_enrolled: true });

View File

@@ -46,12 +46,6 @@ function CertificateStatus({ intl }) {
isEnrolled,
userHasPassingGrade,
);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const dispatch = useDispatch();
const { administrator } = getAuthenticatedUser();
@@ -83,21 +77,17 @@ function CertificateStatus({ intl }) {
const idVerificationSupportLink = <IdVerificationSupportLink />;
const profileLink = <ProfileLink />;
// Some learners have a valid ("downloadable") certificate without being in a passing
// state (e.g. learners who have been added to a course's allowlist), so we need to
// skip grade validation for these learners
const certIsDownloadable = certStatus === 'downloadable';
if (mode === COURSE_EXIT_MODES.disabled) {
certEventName = 'certificate_status_disabled';
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) {
} else if (mode === COURSE_EXIT_MODES.nonPassing) {
certCase = 'notPassing';
certEventName = 'not_passing';
body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) {
} else if (mode === COURSE_EXIT_MODES.inProgress) {
certCase = 'inProgress';
certEventName = 'has_scheduled_content';
body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.celebration || certIsDownloadable) {
} else if (mode === COURSE_EXIT_MODES.celebration) {
switch (certStatus) {
case 'requesting':
certCase = 'requestable';
@@ -115,7 +105,6 @@ function CertificateStatus({ intl }) {
<FormattedMessage
id="progress.certificateStatus.unverifiedBody"
defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}."
description="Its shown when learner are not verified thus it recommends going over the verification process"
values={{ idVerificationSupportLink }}
/>
);
@@ -131,10 +120,9 @@ function CertificateStatus({ intl }) {
<FormattedMessage
id="progress.certificateStatus.downloadableBody"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
Showcase your accomplishment on LinkedIn or your resume today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
description="Recommending an action for learner when course certificate is available"
values={{ dashboardLink, profileLink }}
/>
);
@@ -157,9 +145,8 @@ function CertificateStatus({ intl }) {
body = (
<FormattedMessage
id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
defaultMessage="This course ends on {endDate}. Final grades and 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 }}
/>
);
@@ -211,25 +198,18 @@ function CertificateStatus({ intl }) {
is_staff: administrator,
certificate_status_variant: certEventName,
});
if (certCase === 'upgrade') {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: '(none)',
linkName: 'progress_certificate',
linkType: 'button',
pageName: 'progress',
});
}
};
return (
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
<Card className="bg-light-200 raised-card">
<Card.Header title={header} />
<Card.Section className="small text-gray-700">
{body}
</Card.Section>
<Card.Footer>
<Card className="bg-light-200 shadow-sm border-0">
<Card.Body>
<Card.Title>
<h3>{header}</h3>
</Card.Title>
<Card.Text className="small text-gray-700">
{body}
</Card.Text>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
@@ -243,7 +223,7 @@ function CertificateStatus({ intl }) {
{buttonText}
</Button>
)}
</Card.Footer>
</Card.Body>
</Card>
</section>
);

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