Compare commits
1 Commits
open-relea
...
mikix/mfe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d67042660 |
14
.env
14
.env
@@ -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=''
|
||||
@@ -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'
|
||||
12
.env.test
12
.env.test
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
10
.github/workflows/commitlint.yml
vendored
10
.github/workflows/commitlint.yml
vendored
@@ -1,10 +0,0 @@
|
||||
# Run commitlint on the commit messages in a pull request.
|
||||
|
||||
name: Lint Commit Messages
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -1,13 +0,0 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
21
.github/workflows/validate.yml
vendored
21
.github/workflows/validate.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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
1
.husky/_/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint
|
||||
@@ -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
|
||||
|
||||
13
Makefile
13
Makefile
@@ -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:
|
||||
|
||||
108
README.rst
108
README.rst
@@ -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/openedx/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/openedx/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
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -52,70 +66,4 @@ file (which is git-ignored) that defines where to find your local modules, for i
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/openedx/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
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
46960
package-lock.json
generated
46960
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
101
package.json
101
package.json
@@ -4,76 +4,81 @@
|
||||
"description": "Frontend learning application.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-learning.git"
|
||||
"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/openedx/frontend-app-learning#readme",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "^12.0.0",
|
||||
"@edx/frontend-component-header": "^4.0.0",
|
||||
"@edx/frontend-lib-special-exams": "^2.16.1",
|
||||
"@edx/frontend-platform": "^4.2.0",
|
||||
"@edx/paragon": "^20.28.4",
|
||||
"@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.22.2",
|
||||
"history": "^5.3.0",
|
||||
"js-cookie": "3.0.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.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": "2.0.1",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<% } %>
|
||||
|
||||
@@ -5,12 +5,5 @@
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
"rebaseStalePrs": true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default, useAccessExpirationMasqueradeBanner } from './hooks';
|
||||
export { default, useAccessExpirationAlertMasquerade } from './hooks';
|
||||
|
||||
@@ -4,7 +4,6 @@ const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.accessExpiration.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'The anchor text for the upgrading link',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import genericMessages from './messages';
|
||||
|
||||
function ActiveEnterpriseAlert({ intl, payload }) {
|
||||
const { text, courseId } = payload;
|
||||
const changeActiveEnterprise = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={
|
||||
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
|
||||
`${global.location.origin}/course/${courseId}/home`,
|
||||
)}`
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
{text}
|
||||
<FormattedMessage
|
||||
id="learning.activeEnterprise.alert"
|
||||
description="Prompts the user to log-in with the correct enterprise to access the course content."
|
||||
defaultMessage=" {changeActiveEnterprise}."
|
||||
values={{
|
||||
changeActiveEnterprise,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ActiveEnterpriseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ActiveEnterpriseAlert);
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
initializeTestStore, render, screen,
|
||||
} from '../../setupTest';
|
||||
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
|
||||
|
||||
describe('ActiveEnterpriseAlert', () => {
|
||||
const mockData = {
|
||||
payload: {
|
||||
text: 'test message',
|
||||
courseId: 'test-course-id',
|
||||
},
|
||||
};
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
});
|
||||
|
||||
it('Shows alert message and links', () => {
|
||||
render(<ActiveEnterpriseAlert {...mockData} />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute(
|
||||
'href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
|
||||
|
||||
export default function useActiveEnterpriseAlert(courseId) {
|
||||
const { courseAccess } = useModel('courseHomeMeta', courseId);
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. course access code is incorrect_active_enterprise
|
||||
*/
|
||||
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
|
||||
|
||||
const payload = {
|
||||
text: courseAccess && courseAccess.userMessage,
|
||||
courseId,
|
||||
};
|
||||
useAlert(isVisible, {
|
||||
code: 'clientActiveEnterpriseAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
});
|
||||
|
||||
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import useActiveEnterpriseAlert from './hooks';
|
||||
|
||||
export default useActiveEnterpriseAlert;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
changeActiveEnterpriseLowercase: {
|
||||
id: 'learning.activeEnterprise.change.alert',
|
||||
defaultMessage: 'change enterprise now',
|
||||
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default, useCourseStartMasqueradeBanner } from './hooks';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
34
src/course-header/AnonymousUserMenu.jsx
Normal file
34
src/course-header/AnonymousUserMenu.jsx
Normal 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);
|
||||
74
src/course-header/AuthenticatedUserDropdown.jsx
Normal file
74
src/course-header/AuthenticatedUserDropdown.jsx
Normal 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);
|
||||
@@ -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)}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
97
src/course-header/Header.jsx
Normal file
97
src/course-header/Header.jsx
Normal 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);
|
||||
29
src/course-header/Header.test.jsx
Normal file
29
src/course-header/Header.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as Header } from './Header';
|
||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
||||
46
src/course-header/messages.js
Normal file
46
src/course-header/messages.js
Normal 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;
|
||||
@@ -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_view_certificate: 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,
|
||||
});
|
||||
|
||||
@@ -219,4 +219,5 @@ Factory.define('datesTabData')
|
||||
],
|
||||
has_ended: false,
|
||||
learner_is_full_access: true,
|
||||
user_timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -35,13 +36,11 @@ Factory.define('outlineTabData')
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
selected_goal: null,
|
||||
weekly_learning_goal_enabled: false,
|
||||
days_per_week: null,
|
||||
subscribed_to_reminders: null,
|
||||
},
|
||||
course_tools: [
|
||||
{
|
||||
@@ -49,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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
"canViewCertificate": 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,91 +311,79 @@ 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 {
|
||||
"canViewCertificate": 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 {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
"downloadUrl": null,
|
||||
},
|
||||
"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",
|
||||
],
|
||||
@@ -426,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 [
|
||||
@@ -440,10 +409,11 @@ 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",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "Title of Sequence",
|
||||
@@ -451,11 +421,8 @@ Object {
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
@@ -463,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,
|
||||
@@ -471,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",
|
||||
@@ -504,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,
|
||||
@@ -528,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 {
|
||||
"canViewCertificate": 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,
|
||||
@@ -612,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",
|
||||
@@ -635,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",
|
||||
@@ -645,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",
|
||||
@@ -679,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",
|
||||
@@ -706,12 +630,5 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
@@ -148,9 +141,12 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
effortTime: block.effort_time,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
|
||||
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
||||
showLink: !!block.lms_web_url,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
// The presence of an legacy URL for the sequence indicates that we want this
|
||||
// sequence to be a clickable link in the outline (even though, if the new
|
||||
// courseware experience is active, we will ignore `legacyWebUrl` and build a
|
||||
// link to the MFE ourselves).
|
||||
showLink: !!block.legacy_web_url,
|
||||
title: block.display_name,
|
||||
};
|
||||
break;
|
||||
@@ -186,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.
|
||||
@@ -199,23 +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 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.
|
||||
@@ -232,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)}`;
|
||||
}
|
||||
@@ -290,20 +310,6 @@ export async function getProctoringInfoData(courseId, username) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLiveTabIframe(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||
@@ -320,10 +326,26 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
}
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const requestTime = Date.now();
|
||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
||||
const responseTime = Date.now();
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||
let { tabData } = {};
|
||||
let requestTime = Date.now();
|
||||
let responseTime = requestTime;
|
||||
try {
|
||||
requestTime = Date.now();
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
responseTime = Date.now();
|
||||
} 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 null;
|
||||
}
|
||||
if (processTabDataErrorRedirect(error)) {
|
||||
return null; // keeps loading screen active
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -338,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;
|
||||
|
||||
@@ -361,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,
|
||||
};
|
||||
@@ -383,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 });
|
||||
}
|
||||
|
||||
@@ -414,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));
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ export {
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
deprecatedSaveCourseGoal,
|
||||
saveWeeklyLearningGoal,
|
||||
saveCourseGoal,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
|
||||
@@ -1,221 +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`,
|
||||
}),
|
||||
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`,
|
||||
},
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
deprecatedPostCourseGoals,
|
||||
postWeeklyLearningGoal,
|
||||
postCourseGoals,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
} from '../../generic/model-store';
|
||||
|
||||
import {
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
@@ -33,38 +30,48 @@ const eventTypes = {
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadata,
|
||||
},
|
||||
}));
|
||||
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
|
||||
if (tabDataResult) {
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
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,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult,
|
||||
...tabDataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (tabDataResult || !getTabData) {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
targetUserId,
|
||||
}));
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
logError(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,14 +87,6 @@ export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function fetchLiveTab(courseId) {
|
||||
return fetchTab(courseId, 'live', getLiveTabIframe);
|
||||
}
|
||||
|
||||
export function fetchDiscussionTab(courseId) {
|
||||
return fetchTab(courseId, 'discussion');
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
@@ -111,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) {
|
||||
|
||||
@@ -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/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
|
||||
it('redirects to the home page when unenrolled', async () => {
|
||||
await renderDenied('enrollment_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
|
||||
|
||||
function DiscussionTab() {
|
||||
const { courseId } = useSelector(state => state.courseHome);
|
||||
const { path } = useParams();
|
||||
const [originalPath] = useState(path);
|
||||
const history = useHistory();
|
||||
|
||||
const [, iFrameHeight] = useIFrameHeight();
|
||||
useIFramePluginEvents({
|
||||
'discussions.navigate': (payload) => {
|
||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||
history.push(`${basePath}/${payload.path}`);
|
||||
},
|
||||
});
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
||||
return (
|
||||
<iframe
|
||||
src={discussionsUrl}
|
||||
className="d-flex w-100 border-0"
|
||||
height={iFrameHeight}
|
||||
style={{ minHeight: '60rem' }}
|
||||
title="discussion"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionTab.propTypes = {};
|
||||
|
||||
export default injectIntl(DiscussionTab);
|
||||
@@ -1,61 +0,0 @@
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
import {
|
||||
initializeMockApp, messageEvent, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { TabContainer } from '../../tab-page';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
import { fetchDiscussionTab } from '../data/thunks';
|
||||
import DiscussionTab from './DiscussionTab';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('DiscussionTab', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/discussion">
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
|
||||
const { id: courseId } = courseMetadata;
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
|
||||
it('resizes when it gets a size hint from iframe', async () => {
|
||||
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
|
||||
await waitFor(() => expect(screen.getByTitle('discussion'))
|
||||
.toHaveAttribute('height', String(1234)));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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('You’ve 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -1,3 +0,0 @@
|
||||
import GoalUnsubscribe from './GoalUnsubscribe';
|
||||
|
||||
export default GoalUnsubscribe;
|
||||
@@ -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: 'You’ve 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;
|
||||
@@ -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 |
@@ -1,22 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
function LiveTab() {
|
||||
const { courseId } = useSelector(state => state.courseHome);
|
||||
const liveModel = useSelector(state => state.models.live);
|
||||
useEffect(() => {
|
||||
const iframe = document.getElementById('lti-tab-embed');
|
||||
if (iframe) {
|
||||
iframe.className += ' vh-100 w-100 border-0';
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
id="live_tab"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveTab;
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
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 { history } from '@edx/frontend-platform';
|
||||
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';
|
||||
@@ -35,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 {
|
||||
@@ -52,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 = {
|
||||
@@ -76,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);
|
||||
@@ -97,42 +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';
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const currentParams = new URLSearchParams(location.search);
|
||||
const startCourse = currentParams.get('start_course');
|
||||
if (startCourse === '1') {
|
||||
sendTrackEvent('welcome.email.clicked.startcourse', {});
|
||||
|
||||
// Deleting the course_start query param as it only needs to be set once
|
||||
// whenever passed in query params.
|
||||
currentParams.delete('start_course');
|
||||
history.replace({
|
||||
search: currentParams.toString(),
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
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">
|
||||
@@ -153,6 +150,7 @@ function OutlineTab({ intl }) {
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...accessExpirationAlertMasquerade,
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
@@ -166,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}
|
||||
@@ -193,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} />
|
||||
@@ -212,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>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
@@ -10,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 {
|
||||
@@ -21,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');
|
||||
@@ -29,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);
|
||||
}
|
||||
|
||||
@@ -52,14 +45,9 @@ describe('Outline Tab', () => {
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender(path = '') {
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<OutlineTab />
|
||||
</MemoryRouter>,
|
||||
{ store },
|
||||
));
|
||||
await act(async () => render(<OutlineTab />, { store }));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -69,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',
|
||||
@@ -83,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 () => {
|
||||
@@ -144,8 +131,25 @@ describe('Outline Tab', () => {
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays link', async () => {
|
||||
it('SequenceLink displays points to legacy courseware', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: false,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to courseware MFE', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: true,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
@@ -321,165 +325,89 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start or Resume Course Card', () => {
|
||||
it('renders startOrResumeCourseCard', async () => {
|
||||
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();
|
||||
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');
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('post goal via query param', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
await fetchAndRender('http://localhost/?weekly_goal=3');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.setgoal', {});
|
||||
});
|
||||
|
||||
it('emit start course event via query param', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender('http://localhost/?start_course=1');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.startcourse', {});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -509,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', () => {
|
||||
@@ -528,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();
|
||||
@@ -538,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();
|
||||
});
|
||||
@@ -564,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', { exact: false })).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -601,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
|
||||
@@ -610,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
|
||||
@@ -668,6 +640,7 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: tomorrow.toISOString(),
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -684,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();
|
||||
@@ -695,6 +668,7 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -717,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();
|
||||
@@ -763,6 +703,7 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -791,12 +732,55 @@ 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',
|
||||
});
|
||||
});
|
||||
it('tracks download cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
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: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
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();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks unverified cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
@@ -807,6 +791,7 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -835,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',
|
||||
});
|
||||
@@ -878,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();
|
||||
});
|
||||
});
|
||||
@@ -894,6 +879,7 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: 'certificate/testuuid',
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -919,6 +905,7 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -935,6 +922,33 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate (pdf) Complete Alert', () => {
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: 'download/url',
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proctoring Info Panel', () => {
|
||||
const onboardingReleaseDate = new Date();
|
||||
onboardingReleaseDate.setDate(new Date().getDate() - 7);
|
||||
@@ -1017,22 +1031,6 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('displays expiration warning', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays expiration warning for other course', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
@@ -1044,23 +1042,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays expired', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message appears after expiration, set the date 10 days in the past
|
||||
expirationDate.setTime(expirationDate.getTime() - 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('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.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1081,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();
|
||||
});
|
||||
|
||||
@@ -1195,7 +1176,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Activation Alert', () => {
|
||||
describe('Accont Activation Alert', () => {
|
||||
beforeEach(() => {
|
||||
const intersectionObserverMock = () => ({
|
||||
observe: () => null,
|
||||
@@ -1223,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(); });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
@@ -27,16 +28,27 @@ function SequenceLink({
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
legacyWebUrl,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
userTimezone,
|
||||
datesWidget: {
|
||||
userTimezone,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
const {
|
||||
canLoadCourseware,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
|
||||
const coursewareUrl = (
|
||||
canLoadCourseware
|
||||
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
|
||||
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
|
||||
);
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,10 +33,9 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
} = payload;
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
@@ -65,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,
|
||||
@@ -78,7 +77,11 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
);
|
||||
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
if (isWebCert) {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
}
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = certURL;
|
||||
alertProps.buttonAction = () => {
|
||||
@@ -115,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:
|
||||
@@ -144,9 +129,6 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
alertProps = renderNotIDVerifiedStatus();
|
||||
break;
|
||||
default:
|
||||
if (notPassingCourseEnded) {
|
||||
alertProps = renderNotPassingCourseEnded();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -199,14 +181,9 @@ CertificateStatusAlert.propTypes = {
|
||||
courseEndDate: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
certURL: PropTypes.string,
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,60 +21,39 @@ 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 {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isVerifiedEnrollmentMode = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
|
||||
);
|
||||
const isWebCert = downloadUrl === null;
|
||||
|
||||
let certURL = '';
|
||||
if (certWebViewUrl) {
|
||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
} else if (downloadUrl) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
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,
|
||||
@@ -82,12 +61,11 @@ function useCertificateStatusAlert(courseId) {
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
};
|
||||
|
||||
useAlert(isVisible || notPassingCourseEnded, {
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCertificateStatusAlert',
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelativeTime,
|
||||
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
|
||||
|
||||
@@ -21,7 +21,7 @@ function CourseEndAlert({ payload }) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelativeTime
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
@@ -78,7 +78,7 @@ function CourseEndAlert({ payload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
<Alert type={ALERT_TYPES.INFO}>
|
||||
<strong>{msg}</strong><br />
|
||||
{description}
|
||||
</Alert>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -3,45 +3,34 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelativeTime,
|
||||
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_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
||||
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 } : {};
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
const timeRemaining = (
|
||||
<FormattedRelativeTime
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={delta / 1000}
|
||||
numeric="auto"
|
||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
||||
updateIntervalInSeconds={YEAR_SEC}
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
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}."
|
||||
@@ -66,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"
|
||||
@@ -91,7 +80,6 @@ function CourseStartAlert({ payload }) {
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.calendar"
|
||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
||||
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
@@ -99,7 +87,8 @@ function CourseStartAlert({ payload }) {
|
||||
|
||||
CourseStartAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
courseId: PropTypes.string,
|
||||
startDate: PropTypes.string,
|
||||
userTimezone: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './hooks';
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 you’re not quite at your goal, we’ll 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,145 +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 hasn’t 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',
|
||||
},
|
||||
expiredProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expired',
|
||||
defaultMessage: 'Expired',
|
||||
description: 'A label to indicate that proctortrack onboarding exam has expired',
|
||||
},
|
||||
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. 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)',
|
||||
},
|
||||
expiredProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expired',
|
||||
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
|
||||
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.',
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
94
src/course-home/outline-tab/widgets/CourseGoalCard.jsx
Normal file
94
src/course-home/outline-tab/widgets/CourseGoalCard.jsx
Normal 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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
@@ -35,7 +24,6 @@ function ProctoringInfoPanel({ intl }) {
|
||||
error: 'error',
|
||||
otherCourseApproved: 'otherCourseApproved',
|
||||
expiringSoon: 'expiringSoon',
|
||||
expired: 'expired',
|
||||
};
|
||||
|
||||
function getReadableStatusClass(examStatus) {
|
||||
@@ -55,14 +43,9 @@ function ProctoringInfoPanel({ intl }) {
|
||||
return readableClass;
|
||||
}
|
||||
|
||||
function isCurrentlySubmitted(examStatus) {
|
||||
const SUBMITTED_STATES = ['submitted', 'second_review_required'];
|
||||
return SUBMITTED_STATES.includes(examStatus);
|
||||
}
|
||||
|
||||
function isSubmissionRequired(examStatus) {
|
||||
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
|
||||
return !OK_STATES.includes(examStatus);
|
||||
function isNotYetSubmitted(examStatus) {
|
||||
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
|
||||
return !NO_SHOW_STATES.includes(examStatus);
|
||||
}
|
||||
|
||||
function isNotYetReleased(examReleaseDate) {
|
||||
@@ -83,19 +66,11 @@ function ProctoringInfoPanel({ intl }) {
|
||||
return borderClass;
|
||||
}
|
||||
|
||||
function isExpired(dateString) {
|
||||
// Returns true if the expiration date has passed
|
||||
const today = new Date();
|
||||
const expirationDateObject = new Date(dateString);
|
||||
return today >= expirationDateObject.getTime();
|
||||
}
|
||||
|
||||
function isExpiringSoon(dateString) {
|
||||
// Returns true if the expiration date is within 28 days
|
||||
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
|
||||
const today = new Date();
|
||||
const expirationDateObject = new Date(dateString);
|
||||
return today > expirationDateObject.getTime() - twentyeightDays;
|
||||
return today > expirationDateObject.getTime() - 2419200000;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,75 +78,23 @@ 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;
|
||||
if (expirationDate && isExpired(expirationDate)) {
|
||||
setReadableStatus(getReadableStatusClass('expired'));
|
||||
} else if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||
if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||
setReadableStatus(getReadableStatusClass('expiringSoon'));
|
||||
} else {
|
||||
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>
|
||||
@@ -191,18 +114,51 @@ function ProctoringInfoPanel({ intl }) {
|
||||
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
|
||||
<>
|
||||
<p>
|
||||
{!isCurrentlySubmitted(status) && (
|
||||
intl.formatMessage(messages.proctoringPanelGeneralInfo)
|
||||
{isNotYetSubmitted(status) && (
|
||||
<>
|
||||
{intl.formatMessage(messages.proctoringPanelGeneralInfo)}
|
||||
</>
|
||||
)}
|
||||
{isCurrentlySubmitted(status) && (
|
||||
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
|
||||
{!isNotYetSubmitted(status) && (
|
||||
<>
|
||||
{intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
|
||||
</>
|
||||
)}
|
||||
{isSubmissionRequired(readableStatus) && (
|
||||
onboardingExamButton
|
||||
{isNotYetSubmitted(status) && (
|
||||
<>
|
||||
{!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)}
|
||||
@@ -215,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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user