Compare commits

..

1 Commits

Author SHA1 Message Date
David Joy
c049e391fe Create 0009-discussions-integration.md 2021-04-15 14:24:39 -04:00
416 changed files with 19777 additions and 57712 deletions

75
.env
View File

@@ -1,45 +1,32 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production' NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=null
ACCESS_TOKEN_COOKIE_NAME='' BASE_URL=null
BASE_URL='' CREDENTIALS_BASE_URL=null
CONTACT_URL='' CSRF_TOKEN_API_PATH=null
CREDENTIALS_BASE_URL='' DISCOVERY_API_BASE_URL=null
CREDIT_HELP_LINK_URL='' ENTERPRISE_LEARNER_PORTAL_HOSTNAME=null
CSRF_TOKEN_API_PATH='' ECOMMERCE_BASE_URL=null
DISCOVERY_API_BASE_URL='' INSIGHTS_BASE_URL=null
DISCUSSIONS_MFE_BASE_URL='' LANGUAGE_PREFERENCE_COOKIE_NAME=null
ECOMMERCE_BASE_URL='' LMS_BASE_URL=null
ENABLE_JUMPNAV='true' LOGIN_URL=null
ENABLE_NOTICES='' LOGOUT_URL=null
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='' LOGO_URL=null
FAVICON_URL='' LOGO_TRADEMARK_URL=null
IGNORED_ERROR_REGEX='' LOGO_WHITE_URL=null
INSIGHTS_BASE_URL='' FAVICON_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME='' MARKETING_SITE_BASE_URL=null
LMS_BASE_URL='' ORDER_HISTORY_URL=null
LOGIN_URL='' REFRESH_ACCESS_TOKEN_ENDPOINT=null
LOGOUT_URL='' SEARCH_CATALOG_URL=null
LOGO_URL='' SEGMENT_KEY=null
LOGO_TRADEMARK_URL='' SITE_NAME=null
LOGO_WHITE_URL='' SOCIAL_UTM_MILESTONE_CAMPAIGN=null
LEGACY_THEME_NAME='' STUDIO_BASE_URL=null
MARKETING_SITE_BASE_URL='' SUPPORT_URL=null
ORDER_HISTORY_URL='' SUPPORT_URL_CALCULATOR_MATH=null
REFRESH_ACCESS_TOKEN_ENDPOINT='' SUPPORT_URL_ID_VERIFICATION=null
SEARCH_CATALOG_URL='' SUPPORT_URL_VERIFIED_CERTIFICATE=null
SEGMENT_KEY='' TWITTER_HASHTAG=null
SESSION_COOKIE_DOMAIN='' TWITTER_URL=null
SITE_NAME='' USER_INFO_COOKIE_NAME=null
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
STUDIO_BASE_URL=''
SUPPORT_URL=''
SUPPORT_URL_CALCULATOR_MATH=''
SUPPORT_URL_ID_VERIFICATION=''
SUPPORT_URL_VERIFIED_CERTIFICATE=''
TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''

View File

@@ -1,22 +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' NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000' BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150' 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' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' 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' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000' LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login' LOGIN_URL='http://localhost:18000/login'
@@ -24,13 +13,13 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.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 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' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses' SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY='' SEGMENT_KEY=null
SITE_NAME='edX' SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone' SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010' STUDIO_BASE_URL='http://localhost:18010'
@@ -38,8 +27,6 @@ SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator' SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity' SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate' SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'

View File

@@ -1,22 +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' NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000' BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150' 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' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' 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' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000' LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login' LOGIN_URL='http://localhost:18000/login'
@@ -24,13 +13,13 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.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 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' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses' SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY='' SEGMENT_KEY=null
SITE_NAME='edX' SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone' SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010' STUDIO_BASE_URL='http://localhost:18010'
@@ -38,7 +27,6 @@ SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator' SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity' SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate' SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,5 +1,4 @@
coverage/* coverage/*
dist/ dist/
packages/
node_modules/ node_modules/
jest.config.js jest.config.js

View File

@@ -1,17 +1,11 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@edx/frontend-build'); const { createConfig } = require('@edx/frontend-build');
const config = createConfig('eslint', { module.exports = createConfig('eslint', {
rules: { overrides: [{
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release. files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
'react-hooks/rules-of-hooks': 'off', rules: {
'react-hooks/exhaustive-deps': 'off', 'import/named': 'off',
'import/no-extraneous-dependencies': 'off', 'import/no-extraneous-dependencies': 'off',
'no-restricted-exports': 'off', },
'react/jsx-no-useless-fragment': 'off', }],
'react/no-unknown-property': 'off',
'func-names': 'off',
},
}); });
module.exports = config;

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
name: Update Browserslist DB
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-browserslist:
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
secrets:
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}

View File

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

4
.gitignore vendored
View File

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

1
.husky/_/.gitignore vendored
View File

@@ -1 +0,0 @@
*

View File

@@ -1,31 +0,0 @@
#!/bin/sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
export readonly husky_skip_init=1
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
exit $exitCode
fi

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

View File

@@ -1,9 +1,8 @@
[main] [main]
host = https://www.transifex.com 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 file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json source_file = src/i18n/transifex_input.json
source_lang = en source_lang = en
type = KEYVALUEJSON type = KEYVALUEJSON

View File

@@ -1,9 +1,11 @@
export TRANSIFEX_RESOURCE=frontend-app-learning transifex_resource = frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA" transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json 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 . # This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl transifex_temp = ./temp/babel-plugin-react-intl
@@ -36,15 +38,15 @@ push_translations:
# Pushing strings to Transifex... # Pushing strings to Transifex...
tx push -s tx push -s
# Fetching hashes from Transifex... # 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... # Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path $(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex... # 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. # Pulls translations from Transifex.
pull_translations: pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs) tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis. # This target is used by Travis.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:
@@ -58,6 +60,7 @@ validate:
npm run lint -- --max-warnings 0 npm run lint -- --max-warnings 0
npm run test npm run test
npm run build npm run build
npm run is-es5
.PHONY: validate.ci .PHONY: validate.ci
validate.ci: validate.ci:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
# Discussions Integration
The discussions project will ultimately need to embed code from a separate micro-frontend into frontend-app-learning in order to render in-context discussions. In-context, in this case, means as a sidebar to unit-level content. The idea is that the frontend-app-discussions MFE would have routes that render unit-specific discussions forum as a companion/sidebar to the main unit content.
This ADR describes some design considerations for this integration.
This integration is a forerunner to a broader project around the implementation of "experience plugins" which would allow us to load code across micro-frontends without an iframe. That said, that project hasn't kicked off yet, so in the meantime we'd like to move forward with a simple iframe-based approach, very similar to how we load the unit content from LMS.
Furthermore, we will need to revisit this if it ever becomes the case that an LTI-based discussions provider implements in-context discussions. Our approach should ideally be roughly compatible with LTI concepts and techniques, though I'd strongly suggest we _don't_ build anything LTI-specific for the foreseeable future.
The suggested implementation involves:
- Updating the sequence metadata API to include information about whether a particular unit should load in-context discussions. For now, this could take the form of a `discussions_url` added to the unit objects inside the `items` array. Longer term we'll want a way to signal to the frontend-app-learning MFE the plugins that should be available on the page, but that likely will come from some other API. I don't think we should worry about that broader plugin configuration as part of this.
- Adding a new `InContextSidebar` React component to frontend-app-learning to house the discussions integration. The medium to long-term goal is that discussions will not be the only experience plugin loaded in this sidebar, so it would be appropriate to add a thin layer of abstraction between the components that render the discussions iframe and the components that handle the sidebar's behavior.
- Have the discussions iframe load the page at `discussions_url`
- If necessary, use a postMessage-based API to communicate into the iframe and pass additional data into it. I don't know of a use-case for this for the discussions project, but wanted to mention it. The schema of these messages would ideally be LTI-friendly, though the LTI specification doesn't have a lot to say about a postMessage schema. This document suggests a reasonable message schema: https://github.com/bracken/lti_messaging

View File

@@ -9,6 +9,4 @@ module.exports = createConfig('jest', {
'src/i18n', 'src/i18n',
'src/.*\\.exp\\..*', 'src/.*\\.exp\\..*',
], ],
testTimeout: 30000,
testEnvironment: 'jsdom'
}); });

48250
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,78 +4,79 @@
"description": "Frontend learning application.", "description": "Frontend learning application.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/openedx/frontend-app-learning.git" "url": "git+https://github.com/edx/frontend-app-learning.git"
}, },
"browserslist": [ "browserslist": [
"extends @edx/browserslist-config" "last 2 versions",
"ie 11"
], ],
"scripts": { "scripts": {
"build": "fedx-scripts webpack", "build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null", "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": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .", "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot", "snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests", "test": "fedx-scripts jest --coverage --passWithNoTests"
"testx": "fedx-scripts jest -t 'Outline Tab' --coverage --passWithNoTests" },
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-learning#readme", "homepage": "https://github.com/edx/frontend-app-learning#readme",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"bugs": { "bugs": {
"url": "https://github.com/openedx/frontend-app-learning/issues" "url": "https://github.com/edx/frontend-app-learning/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.1.0", "@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-component-header": "^3.6.0", "@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-lib-special-exams": "^2.2.0", "@edx/frontend-platform": "1.8.4",
"@edx/frontend-platform": "2.5.1", "@edx/paragon": "13.17.3",
"@edx/paragon": "^20.24.0", "@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/fontawesome-svg-core": "1.3.0", "@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.1.14",
"@fortawesome/react-fontawesome": "0.1.18", "@reduxjs/toolkit": "1.3.6",
"@popperjs/core": "2.11.6", "classnames": "2.2.6",
"@reduxjs/toolkit": "1.8.1", "core-js": "3.6.5",
"classnames": "2.3.2", "lodash.camelcase": "^4.3.0",
"core-js": "3.22.2", "prop-types": "15.7.2",
"history": "^5.3.0", "react": "16.13.1",
"js-cookie": "3.0.1", "react-break": "1.3.2",
"lodash.camelcase": "4.3.0", "react-dom": "16.13.1",
"prop-types": "15.8.1", "react-helmet": "6.0.0",
"query-string": "^7.1.1", "react-redux": "7.2.2",
"react": "16.14.0", "react-router": "5.2.0",
"react-dom": "16.14.0", "react-router-dom": "5.2.0",
"react-helmet": "6.1.0", "react-share": "4.2.1",
"react-redux": "7.2.9", "redux": "4.0.5",
"react-router": "5.2.1", "regenerator-runtime": "0.13.7",
"react-router-dom": "5.3.0", "reselect": "4.0.0",
"react-share": "^4.4.1", "truncate-html": "1.0.3"
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"truncate-html": "1.0.4",
"util": "0.12.5"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.1.1", "@edx/frontend-build": "5.5.5",
"@edx/frontend-build": "^12.4.15", "@testing-library/dom": "7.16.3",
"@edx/reactifex": "2.1.1", "@testing-library/jest-dom": "5.10.1",
"@pact-foundation/pact": "9.17.3", "@testing-library/react": "10.3.0",
"@testing-library/jest-dom": "5.16.5", "@testing-library/user-event": "12.0.17",
"@testing-library/react": "12.1.5", "axios-mock-adapter": "1.18.2",
"@testing-library/user-event": "13.5.0", "codecov": "3.8.1",
"axios-mock-adapter": "1.20.0", "es-check": "5.1.4",
"copy-webpack-plugin": "^11.0.0", "glob": "7.1.6",
"es-check": "6.2.1", "husky": "3.1.0",
"husky": "7.0.4", "jest": "24.9.0",
"jest": "27.5.1", "jest-chain": "1.1.5",
"rosie": "2.1.0" "reactifex": "1.1.1",
"rosie": "2.0.1"
} }
} }

View File

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

View File

@@ -5,12 +5,5 @@
"patch": { "patch": {
"automerge": true "automerge": true
}, },
"rebaseStalePrs": true, "rebaseStalePrs": true
"packageRules": [
{
"matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
]
} }

View File

@@ -1,14 +1,25 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { import {
FormattedMessage, FormattedDate, injectIntl, intlShape, FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon'; import { Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages'; import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
function AccessExpirationAlert({ intl, payload }) {
/** [MM-P2P] Experiment */
const [showMMP2P, setShowMMP2P] = useState(!!window.experiment__home_alert_bShowMMP2P);
if (window.experiment__home_alert_showMMP2P === undefined) {
window.experiment__home_alert_showMMP2P = (val) => {
window.experiment__home_alert_bShowMMP2P = !!val;
setShowMMP2P(!!val);
};
}
const AccessExpirationAlert = ({ intl, payload }) => {
const { const {
accessExpiration, accessExpiration,
courseId, courseId,
@@ -24,10 +35,41 @@ const AccessExpirationAlert = ({ intl, payload }) => {
const { const {
expirationDate, expirationDate,
masqueradingExpiredCourse,
upgradeDeadline, upgradeDeadline,
upgradeUrl, upgradeUrl,
} = accessExpiration; } = accessExpiration;
if (masqueradingExpiredCourse) {
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>
);
}
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (
<AccessExpirationAlertMMP2P payload={payload} />
);
}
const logClick = () => { const logClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', { sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org, org_key: org,
@@ -47,7 +89,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.deadline" id="learning.accessExpiration.deadline"
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site." 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={{ values={{
date: ( date: (
<FormattedDate <FormattedDate
@@ -75,12 +116,11 @@ const AccessExpirationAlert = ({ intl, payload }) => {
} }
return ( return (
<Alert variant="info" icon={Info}> <Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold"> <span className="font-weight-bold">
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.header" id="learning.accessExpiration.header"
defaultMessage="Audit Access Expires {date}" defaultMessage="Audit Access Expires {date}"
description="Headline for auditing deadline"
values={{ values={{
date: ( date: (
<FormattedDate <FormattedDate
@@ -99,7 +139,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.body" id="learning.accessExpiration.body"
defaultMessage="You lose all access to this course, including your progress, on {date}." 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={{ values={{
date: ( date: (
<FormattedDate <FormattedDate
@@ -116,7 +155,7 @@ const AccessExpirationAlert = ({ intl, payload }) => {
{deadlineMessage} {deadlineMessage}
</Alert> </Alert>
); );
}; }
AccessExpirationAlert.propTypes = { AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
function AccessExpirationAlertMMP2P({ payload }) {
const {
accessExpiration,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (!accessExpiration) {
return null;
}
const {
expirationDate,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
let deadlineMessage = null;
const formatDate = (val, key) => (
<FormattedDate
key={`accessExpiration.${key}`}
day="numeric"
month="short"
year="numeric"
value={val}
{...timezoneFormatArgs}
/>
);
if (upgradeDeadline && upgradeUrl) {
deadlineMessage = (
<>
Upgrade by {formatDate(upgradeDeadline, 'upgradeDesc')} to unlock unlimited access to all course activities, including graded assignments.
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{messages.upgradeNow.defaultMessage}
</Hyperlink>
</>
);
}
return (
<Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold">
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
</span>
<br />
{deadlineMessage}
<br />
You lose all access to the first two weeks of scheduled content
on {formatDate(expirationDate, 'expirationBody')}.
</Alert>
);
}
AccessExpirationAlertMMP2P.propTypes = {
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(AccessExpirationAlertMMP2P);

View File

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

View File

@@ -1,51 +1,25 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages'; import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert')); const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) { 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 = useMemo(() => ({ const payload = {
accessExpiration, accessExpiration,
courseId, courseId,
org, org,
userTimezone, userTimezone,
analyticsPageName, analyticsPageName,
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientAccessExpirationAlert', code: 'clientAccessExpirationAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic, topic,
}); });
return { clientAccessExpirationAlert: AccessExpirationAlert }; 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;
const payload = useMemo(() => ({
expirationDate,
userTimezone,
}), [expirationDate, userTimezone]);
useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner',
payload,
topic: 'instructor-toolbar-alerts',
});
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
}
export default useAccessExpirationAlert; export default useAccessExpirationAlert;

View File

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

View File

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

View File

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

View File

@@ -1,25 +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`);
});
});

View File

@@ -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 = useMemo(() => ({
text: courseAccess && courseAccess.userMessage,
courseId,
}), [courseAccess, courseId]);
useAlert(isVisible, {
code: 'clientActiveEnterpriseAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
payload,
});
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
}

View File

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

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
function IsStartDateInFuture(courseId) {
const {
start,
} = useModel('courseHomeMeta', courseId);
const today = new Date();
const startDate = new Date(start);
return startDate > today;
}
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
const payload = useMemo(() => ({
courseId,
}), [courseId]);
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload,
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 = useMemo(() => ({
courseId,
}), [courseId]);
useAlert(isVisible, {
code: 'clientCourseStartMasqueradeBanner',
payload,
topic: 'instructor-toolbar-alerts',
});
return {
clientCourseStartMasqueradeBanner: CourseStartMasqueradeBanner,
};
}
export default useCourseStartAlert;

View File

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

View File

@@ -1,17 +1,17 @@
import React from 'react'; import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Alert, Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { Info, WarningFilled } from '@edx/paragon/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useModel } from '../../generic/model-store'; import { useModel } from '../../generic/model-store';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages'; import messages from './messages';
import useEnrollClickHandler from './clickHook'; import { useEnrollClickHandler } from './hooks';
const EnrollmentAlert = ({ intl, payload }) => { function EnrollmentAlert({ intl, payload }) {
const { const {
canEnroll, canEnroll,
courseId, courseId,
@@ -30,32 +30,30 @@ const EnrollmentAlert = ({ intl, payload }) => {
); );
let text = intl.formatMessage(messages.alert); let text = intl.formatMessage(messages.alert);
let type = 'warning'; let type = ALERT_TYPES.ERROR;
let icon = WarningFilled;
if (isStaff) { if (isStaff) {
text = intl.formatMessage(messages.staffAlert); text = intl.formatMessage(messages.staffAlert);
type = 'info'; type = ALERT_TYPES.INFO;
icon = Info;
} else if (extraText) { } else if (extraText) {
text = `${text} ${extraText}`; text = `${text} ${extraText}`;
} }
const button = canEnroll && ( 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)} {intl.formatMessage(messages.enrollNowSentence)}
</Button> </Button>
); );
return ( return (
<Alert variant={type} icon={icon}> <Alert type={type}>
<div className="d-flex"> {text}
{text} {' '}
{button} {button}
{loading && <FontAwesomeIcon icon={faSpinner} spin />} {' '}
</div> {loading && <FontAwesomeIcon icon={faSpinner} spin />}
</Alert> </Alert>
); );
}; }
EnrollmentAlert.propTypes = { EnrollmentAlert.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

@@ -1,12 +1,15 @@
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
import React, { import React, {
useContext, useMemo, useContext, useState, useCallback, useMemo,
} from 'react'; } from 'react';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react'; 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 { useModel } from '../../generic/model-store';
import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert')); const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) { export function useEnrollmentAlert(courseId) {
@@ -22,18 +25,43 @@ export function useEnrollmentAlert(courseId) {
* 3. the course is private. * 3. the course is private.
*/ */
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline; const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
const payload = useMemo(() => ({ const payload = {
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false, canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId, courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '', extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff, isStaff: course && course.isStaff,
}), [course, courseId, outline]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientEnrollmentAlert', code: 'clientEnrollmentAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline', topic: 'outline',
}); });
return { clientEnrollmentAlert: EnrollmentAlert }; return { clientEnrollmentAlert: EnrollmentAlert };
} }
export function useEnrollClickHandler(courseId, orgId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
setLoading(true);
postCourseEnrollment(courseId).then(() => {
addFlash({
dismissible: true,
flash: true,
text: successText,
type: ALERT_TYPES.SUCCESS,
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);
return { enrollClickHandler, loading };
}

View File

@@ -1,132 +0,0 @@
import React, { useState } from 'react';
import Cookies from 'js-cookie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
AlertModal,
Button,
Spinner,
Icon,
} from '@edx/paragon';
import { Check, ArrowForward } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
const AccountActivationAlert = ({
intl,
}) => {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
const handleOnClick = () => {
setShowSpinner(true);
setShowCheck(false);
sendActivationEmail().then(() => {
setShowSpinner(false);
setShowCheck(true);
});
};
const showAccountActivationAlert = Cookies.get('show-account-activation-popup');
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
if (Cookies.get('show-account-activation-popup') === undefined) {
setShowModal(true);
}
}
const button = (
<Button
variant="primary"
className=""
onClick={() => setShowModal(false)}
>
<FormattedMessage
id="account-activation.alert.button"
defaultMessage="Continue to {siteName}"
description="account activation alert continue button"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
<Icon src={ArrowForward} className="ml-1 d-inline-block align-bottom" />
</Button>
);
const children = () => {
let bodyContent;
const message = (
<FormattedMessage
id="account-activation.alert.message"
defaultMessage="We sent an email to {boldEmail} with a link to activate your account. Cant find it? Check your spam folder or
{sendEmailTag}."
description="Message for account activation alert which is shown after the registration"
values={{
boldEmail: <b>{getAuthenticatedUser() && getAuthenticatedUser().email}</b>,
sendEmailTag: (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a href="#" role="button" onClick={handleOnClick}>
<FormattedMessage
id="account-activation.resend.link"
defaultMessage="resend the email"
description="Message for resend link in account activation alert which is shown after the registration"
/>
</a>
),
}}
/>
);
bodyContent = (
<div>
{message}
</div>
);
if (!showCheck && showSpinner) {
bodyContent = (
<div>
{message}
<Spinner
animation="border"
variant="secondary"
style={{ height: '1.5rem', width: '1.5rem' }}
/>
</div>
);
}
if (showCheck && !showSpinner) {
bodyContent = (
<div>
{message}
<Icon
src={Check}
style={{ height: '1.7rem', width: '1.25rem' }}
className="text-success-500 d-inline-block position-fixed"
/>
</div>
);
}
return bodyContent;
};
return (
<AlertModal
isOpen={showModal}
title={intl.formatMessage(messages.accountActivationAlertTitle)}
footerNode={button}
onClose={() => ({})}
>
{children()}
</AlertModal>
);
};
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AccountActivationAlert);

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import { FormattedPricing } from '../../generic/upgrade-button';
import messages from './messages';
function OfferAlert({ intl, payload }) {
const {
analyticsPageName,
courseId,
offer,
org,
userTimezone,
} = payload;
if (!offer) {
return null;
}
const {
code,
expirationDate,
percentage,
upgradeUrl,
} = offer;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const logClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
courserun_key: courseId,
linkCategory: 'welcome',
linkName: `${analyticsPageName}_welcome`,
linkType: 'link',
pageName: analyticsPageName,
});
};
return (
<Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold">
<FormattedMessage
id="learning.offer.header"
defaultMessage="Upgrade by {date} and save {percentage}% [{fullPricing}]"
values={{
date: (
<FormattedDate
key="offerDate"
day="numeric"
month="long"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
fullPricing: <FormattedPricing offer={offer} />,
percentage,
}}
/>
</span>
<br />
<FormattedMessage
id="learning.offer.code"
defaultMessage="Use code {code} at checkout!"
values={{
code: (<b>{code}</b>),
}}
/>
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
onClick={logClick}
>
{intl.formatMessage(messages.upgradeNow)}
</Hyperlink>
</Alert>
);
}
OfferAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
courseId: PropTypes.string.isRequired,
offer: PropTypes.shape({
code: PropTypes.string.isRequired,
discountedPrice: PropTypes.string.isRequired,
expirationDate: PropTypes.string.isRequired,
originalPrice: PropTypes.string.isRequired,
percentage: PropTypes.number.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}).isRequired,
org: PropTypes.string.isRequired,
userTimezone: PropTypes.string.isRequired,
analyticsPageName: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(OfferAlert);

View File

@@ -0,0 +1,25 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages';
const OfferAlert = React.lazy(() => import('./OfferAlert'));
export function useOfferAlert(courseId, offer, org, userTimezone, topic, analyticsPageName) {
const isVisible = !!offer; // if it exists, show it.
const payload = {
analyticsPageName,
courseId,
offer,
org,
userTimezone,
};
useAlert(isVisible, {
code: 'clientOfferAlert',
topic,
payload: useMemo(() => payload, Object.values(payload).sort()),
});
return { clientOfferAlert: OfferAlert };
}
export default useOfferAlert;

View File

@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
upgradeNow: {
id: 'learning.offer.upgradeNow',
defaultMessage: 'Upgrade now',
},
});
export default messages;

View File

@@ -1,57 +0,0 @@
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import messages from './messages';
function useSequenceBannerTextAlert(sequenceId) {
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
// Show Alert that comes along with the sequence
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
code: null,
dismissible: false,
text: sequence.bannerText,
type: ALERT_TYPES.INFO,
topic: 'sequence',
});
}
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
const course = useModel('coursewareMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const {
entranceExamCurrentScore,
entranceExamEnabled,
entranceExamId,
entranceExamMinimumScorePct,
entranceExamPassed,
} = course.entranceExamData || {};
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
let entranceExamText;
if (entranceExamPassed) {
entranceExamText = intl.formatMessage(
messages.entranceExamTextPassed,
{ entranceExamCurrentScore: entranceExamCurrentScore * 100 },
);
} else {
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
entranceExamCurrentScore: entranceExamCurrentScore * 100,
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
});
}
useAlert(entranceExamAlertVisible, {
code: null,
dismissible: false,
text: entranceExamText,
type: ALERT_TYPES.INFO,
topic: 'sequence',
});
}
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };

View File

@@ -1,14 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
entranceExamTextNotPassing: {
id: 'learn.sequence.entranceExamTextNotPassing',
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
},
entranceExamTextPassed: {
id: 'learn.sequence.entranceExamTextPassed',
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
},
});
export default messages;

View File

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

View File

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

View File

@@ -6,28 +6,30 @@ import classNames from 'classnames';
import messages from './messages'; import messages from './messages';
import Tabs from '../generic/tabs/Tabs'; import Tabs from '../generic/tabs/Tabs';
const CourseTabsNavigation = ({ function CourseTabsNavigation({
activeTabSlug, className, tabs, intl, activeTabSlug, className, tabs, intl,
}) => ( }) {
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}> return (
<div className="container-xl"> <div className={classNames('course-tabs-navigation', className)}>
<Tabs <div className="container-fluid">
className="nav-underline-tabs" <Tabs
aria-label={intl.formatMessage(messages.courseMaterial)} className="nav-underline-tabs"
> aria-label={intl.formatMessage(messages.courseMaterial)}
{tabs.map(({ url, title, slug }) => ( >
<a {tabs.map(({ url, title, slug }) => (
key={slug} <a
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })} key={slug}
href={url} className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
> href={url}
{title} >
</a> {title}
))} </a>
</Tabs> ))}
</Tabs>
</div>
</div> </div>
</div> );
); }
CourseTabsNavigation.propTypes = { CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string, activeTabSlug: PropTypes.string,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,4 +2,3 @@ import './courseHomeMetadata.factory';
import './datesTabData.factory'; import './datesTabData.factory';
import './outlineTabData.factory'; import './outlineTabData.factory';
import './progressTabData.factory'; import './progressTabData.factory';
import './upgradeNotificationData.factory';

View File

@@ -14,6 +14,7 @@ Factory.define('outlineTabData')
}) })
.attr('dates_widget', ['date_blocks'], (dateBlocks) => ({ .attr('dates_widget', ['date_blocks'], (dateBlocks) => ({
course_date_blocks: dateBlocks, course_date_blocks: dateBlocks,
user_timezone: 'UTC',
})) }))
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({ .attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
has_visited_course: false, has_visited_course: false,
@@ -28,20 +29,11 @@ Factory.define('outlineTabData')
upgrade_url: `${host}/dashboard`, upgrade_url: `${host}/dashboard`,
})) }))
.attrs({ .attrs({
has_scheduled_content: null,
access_expiration: null, access_expiration: null,
can_show_upgrade_sock: false, can_show_upgrade_sock: false,
cert_data: {
cert_status: null,
cert_web_view_url: null,
certificate_available_date: null,
},
course_goals: { course_goals: {
goal_options: [], goal_options: [],
selected_goal: null, selected_goal: null,
weekly_learning_goal_enabled: false,
days_per_week: null,
subscribed_to_reminders: null,
}, },
course_tools: [ course_tools: [
{ {
@@ -49,6 +41,11 @@ Factory.define('outlineTabData')
title: 'Bookmarks', title: 'Bookmarks',
url: 'https://example.com/bookmarks', url: 'https://example.com/bookmarks',
}, },
{
analytics_id: 'edx.tool.verified_upgrade',
title: 'Upgrade to Verified',
url: 'https://example.com/upgrade',
},
], ],
dates_banner_info: { dates_banner_info: {
content_type_gating_enabled: false, content_type_gating_enabled: false,

View File

@@ -4,34 +4,27 @@ 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. // This set of data may not be realistic, but it is intended to demonstrate many UI results.
Factory.define('progressTabData') Factory.define('progressTabData')
.attrs({ .attrs({
access_expiration: null, certificate_data: null,
end: '3027-03-31T00:00:00Z',
certificate_data: {},
completion_summary: { completion_summary: {
complete_count: 1, complete_count: 1,
incomplete_count: 1, incomplete_count: 1,
locked_count: 0, locked_count: 0,
}, },
course_grade: { course_grade: {
letter_grade: 'pass', percent: 0,
percent: 1, is_passing: false,
is_passing: true,
}, },
credit_course_requirements: null,
section_scores: [ section_scores: [
{ {
display_name: 'First section', display_name: 'First section',
subsections: [ subsections: [
{ {
assignment_type: 'Homework', assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection', display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 0, num_points_earned: 0,
num_points_possible: 3, num_points_possible: 1,
percent_graded: 0.0, percent_graded: 0.0,
problem_scores: [{ earned: 0, possible: 1 }, { earned: 0, possible: 1 }, { earned: 0, possible: 1 }],
show_correctness: 'always', show_correctness: 'always',
show_grades: true, show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection', url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
@@ -48,7 +41,6 @@ Factory.define('progressTabData')
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 1, num_points_possible: 1,
percent_graded: 1.0, percent_graded: 1.0,
problem_scores: [{ earned: 1, possible: 1 }],
show_correctness: 'always', show_correctness: 'always',
show_grades: true, show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection', url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
@@ -61,7 +53,6 @@ Factory.define('progressTabData')
assignment_policies: [ assignment_policies: [
{ {
num_droppable: 1, num_droppable: 1,
num_total: 2,
short_label: 'HW', short_label: 'HW',
type: 'Homework', type: 'Homework',
weight: 1, weight: 1,
@@ -71,13 +62,10 @@ Factory.define('progressTabData')
pass: 0.75, pass: 0.75,
}, },
}, },
has_scheduled_content: false,
studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run', studio_url: 'http://studio.edx.org/settings/grading/course-v1:edX+Test+run',
user_has_passing_grade: false,
verification_data: { verification_data: {
link: null, link: null,
status: 'none', status: 'none',
status_date: null, status_date: null,
}, },
verified_mode: null,
}); });

View File

@@ -1,25 +0,0 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('upgradeNotificationData')
.option('host', 'http://localhost:18000')
.option('dateBlocks', [])
.option('offer', null)
.option('userTimezone', 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',
currencySymbol: '$',
price: 149,
sku: 'ABCD1234',
upgradeUrl: `${host}/dashboard`,
}))
.attr('org', 'edX')
.attrs({
accessExpiration: {
expiration_date: '1950-07-13T02:04:49.040006Z',
},
})
.attr('timeOffsetMillis', 0);

View File

@@ -3,10 +3,8 @@
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = ` exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object { Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
"toastHeader": "", "toastHeader": "",
@@ -15,78 +13,56 @@ Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"sequenceId": null, "sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canViewCertificate": true, "canLoadCourseware": false,
"celebrations": null, "id": "course-v1:edX+DemoX+Demo_Course_1",
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
"isStaff": false, "isStaff": false,
"number": "DemoX", "number": "DemoX",
"org": "edX", "org": "edX",
"originalUserIsStaff": false, "originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [ "tabs": Array [
Object { Object {
"slug": "outline", "slug": "outline",
"title": "Course", "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 { Object {
"slug": "discussion", "slug": "discussion",
"title": "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 { Object {
"slug": "wiki", "slug": "wiki",
"title": "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 { Object {
"slug": "progress", "slug": "progress",
"title": "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 { Object {
"slug": "instructor", "slug": "instructor",
"title": "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 { Object {
"slug": "dates", "slug": "dates",
"title": "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", "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",
},
}, },
}, },
"dates": Object { "dates": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"courseDateBlocks": Array [ "courseDateBlocks": Array [
Object { Object {
"date": "2020-05-01T17:59:41Z", "date": "2020-05-01T17:59:41Z",
@@ -300,31 +276,23 @@ Object {
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5", "verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
}, },
"hasEnded": false, "hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true, "learnerIsFullAccess": true,
"userTimezone": "America/New_York",
}, },
}, },
}, },
"recommendations": Object { "recommendations": Object {
"recommendationsStatus": "loading", "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`] = ` exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object { Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
"toastHeader": "", "toastHeader": "",
@@ -333,90 +301,64 @@ Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"sequenceId": null, "sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canViewCertificate": true, "canLoadCourseware": false,
"celebrations": null, "id": "course-v1:edX+DemoX+Demo_Course_1",
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
"isStaff": false, "isStaff": false,
"number": "DemoX", "number": "DemoX",
"org": "edX", "org": "edX",
"originalUserIsStaff": false, "originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [ "tabs": Array [
Object { Object {
"slug": "outline", "slug": "outline",
"title": "Course", "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 { Object {
"slug": "discussion", "slug": "discussion",
"title": "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 { Object {
"slug": "wiki", "slug": "wiki",
"title": "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 { Object {
"slug": "progress", "slug": "progress",
"title": "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 { Object {
"slug": "instructor", "slug": "instructor",
"title": "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 { Object {
"slug": "dates", "slug": "dates",
"title": "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", "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",
},
}, },
}, },
"outline": Object { "outline": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null, "accessExpiration": null,
"canShowUpgradeSock": false, "canShowUpgradeSock": false,
"certData": Object {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
},
"courseBlocks": Object { "courseBlocks": Object {
"courses": Object { "courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object { "block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false, "effortActivities": undefined,
"id": "course-v1:edX+DemoX+Demo_Course", "effortTime": undefined,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [ "sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
], ],
@@ -426,7 +368,9 @@ Object {
"sections": Object { "sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object { "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false, "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", "id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false, "resumeBlock": false,
"sequenceIds": Array [ "sequenceIds": Array [
@@ -440,10 +384,11 @@ Object {
"complete": false, "complete": false,
"description": null, "description": null,
"due": null, "due": null,
"effortActivities": 2, "effortActivities": undefined,
"effortTime": 15, "effortTime": undefined,
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "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", "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true, "showLink": true,
"title": "Title of Sequence", "title": "Title of Sequence",
@@ -451,11 +396,8 @@ Object {
}, },
}, },
"courseGoals": Object { "courseGoals": Object {
"daysPerWeek": null,
"goalOptions": Array [], "goalOptions": Array [],
"selectedGoal": null, "selectedGoal": null,
"subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
}, },
"courseTools": Array [ "courseTools": Array [
Object { Object {
@@ -463,6 +405,11 @@ Object {
"title": "Bookmarks", "title": "Bookmarks",
"url": "https://example.com/bookmarks", "url": "https://example.com/bookmarks",
}, },
Object {
"analyticsId": "edx.tool.verified_upgrade",
"title": "Upgrade to Verified",
"url": "https://example.com/upgrade",
},
], ],
"datesBannerInfo": Object { "datesBannerInfo": Object {
"contentTypeGatingEnabled": false, "contentTypeGatingEnabled": false,
@@ -471,24 +418,20 @@ Object {
}, },
"datesWidget": Object { "datesWidget": Object {
"courseDateBlocks": Array [], "courseDateBlocks": Array [],
"userTimezone": "UTC",
}, },
"enableProctoredExams": undefined,
"enrollAlert": Object { "enrollAlert": Object {
"canEnroll": true, "canEnroll": true,
"extraText": "Contact the administrator.", "extraText": "Contact the administrator.",
}, },
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>", "handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined, "hasEnded": undefined,
"hasScheduledContent": null, "id": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course",
"offer": null, "offer": null,
"resumeCourse": Object { "resumeCourse": Object {
"hasVisitedCourse": false, "hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde", "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 { "verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00", "accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD", "currency": "USD",
@@ -504,214 +447,5 @@ Object {
"recommendations": Object { "recommendations": Object {
"recommendationsStatus": "loading", "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",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"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",
"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/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/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",
},
},
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"accessExpiration": null,
"certificateData": Object {},
"completionSummary": Object {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": Object {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": "1.00",
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": Object {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course",
"sectionScores": Array [
Object {
"displayName": "First section",
"subsections": Array [
Object {
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
"hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0,
"numPointsPossible": 3,
"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",
},
],
},
Object {
"displayName": "Second section",
"subsections": Array [
Object {
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"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",
},
],
},
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": Object {
"link": null,
"status": "none",
"statusDate": null,
},
"verifiedMode": null,
},
},
},
"recommendations": Object {
"recommendationsStatus": "loading",
},
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
} }
`; `;

View File

@@ -3,112 +3,17 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging'; import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils'; import { appendBrowserTimezoneToUrl } from '../../utils';
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => { function normalizeCourseHomeCourseMetadata(metadata) {
let dropCount = numDroppable;
// Drop the lowest grades
while (dropCount && points.length >= dropCount) {
const lowestScore = Math.min(...points);
const lowestScoreIndex = points.indexOf(lowestScore);
points.splice(lowestScoreIndex, 1);
dropCount--;
}
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);
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
};
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
const gradeByAssignmentType = {};
assignmentPolicies.forEach(assignment => {
// Create an array with the number of total assignments and set the scores to 0
// as placeholders for assignments that have not yet been released
gradeByAssignmentType[assignment.type] = {
grades: Array(assignment.numTotal).fill(0),
numAssignmentsCreated: 0,
numTotalExpectedAssignments: assignment.numTotal,
};
});
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
return;
}
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
// If a subsection's assignment type does not match an assignment policy in Studio,
// we won't be able to include it in this accumulation of grades by assignment type.
// This may happen if a course author has removed/renamed an assignment policy in Studio and
// neglected to update the subsection's of that assignment type
if (!gradeByAssignmentType[assignmentType]) {
return;
}
let {
numAssignmentsCreated,
} = gradeByAssignmentType[assignmentType];
numAssignmentsCreated++;
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
// of expected assignments
gradeByAssignmentType[assignmentType].grades.shift();
}
// Add the graded assignment to the list
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
// Record the created assignment
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
});
});
return assignmentPolicies.map((assignment) => {
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
gradeByAssignmentType[assignment.type].grades,
assignment.weight,
assignment.numDroppable,
);
return {
averageGrade,
numDroppable: assignment.numDroppable,
shortLabel: assignment.shortLabel,
type: assignment.type,
weight: assignment.weight,
weightedGrade,
};
});
}
/**
* Tweak the metadata for consistency
* @param metadata the data to normalize
* @param rootSlug either 'courseware' or 'outline' depending on the context
* @returns {Object} The normalized metadata
*/
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata); const data = camelCaseObject(metadata);
return { return {
...data, ...data,
tabs: data.tabs.map(tab => ({ tabs: data.tabs.map(tab => ({
// The API uses "courseware" as a slug for both courseware and the outline tab. // The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
// If needed, we switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware. // 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, title: tab.title,
url: tab.url, url: tab.url,
})), })),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
}; };
} }
@@ -122,16 +27,19 @@ export function normalizeOutlineBlocks(courseId, blocks) {
switch (block.type) { switch (block.type) {
case 'course': case 'course':
models.courses[block.id] = { models.courses[block.id] = {
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: courseId, id: courseId,
title: block.display_name, title: block.display_name,
sectionIds: block.children || [], sectionIds: block.children || [],
hasScheduledContent: block.has_scheduled_content,
}; };
break; break;
case 'chapter': case 'chapter':
models.sections[block.id] = { models.sections[block.id] = {
complete: block.complete, complete: block.complete,
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: block.id, id: block.id,
title: block.display_name, title: block.display_name,
resumeBlock: block.resume_block, resumeBlock: block.resume_block,
@@ -148,9 +56,12 @@ export function normalizeOutlineBlocks(courseId, blocks) {
effortTime: block.effort_time, effortTime: block.effort_time,
icon: block.icon, icon: block.icon,
id: block.id, id: block.id,
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable legacyWebUrl: block.legacy_web_url,
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves). // The presence of an legacy URL for the sequence indicates that we want this
showLink: !!block.lms_web_url, // 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, title: block.display_name,
}; };
break; break;
@@ -186,11 +97,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
return models; return models;
} }
export async function getCourseHomeCourseMetadata(courseId, rootSlug) { export async function getCourseHomeCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url); url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(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. // For debugging purposes, you might like to see a fully loaded dates tab.
@@ -199,82 +110,40 @@ export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
// import './__factories__'; // import './__factories__';
export async function getDatesTabData(courseId) { export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData')); // 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 { try {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data); return camelCaseObject(data);
} catch (error) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 401) { if (httpErrorStatus === 404) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
// courseAccess in the metadata call, so just ignore this status for now.
return {}; return {};
} }
if (httpErrorStatus === 401) {
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
}
throw error; throw error;
} }
} }
export async function getProgressTabData(courseId, targetUserId) { export async function getProgressTabData(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`; const 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.
if (targetUserId) {
url += `/${targetUserId}/`;
}
try { try {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data); return camelCaseObject(data);
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
camelCasedData.gradingPolicy.assignmentPolicies,
camelCasedData.sectionScores,
);
// 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) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) { if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`); global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {}; return {};
} }
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 {};
}
throw error; throw error;
} }
} }
export async function getProctoringInfoData(courseId, username) { 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) { if (username) {
url += `&username=${encodeURIComponent(username)}`; url += `&username=${encodeURIComponent(username)}`;
} }
@@ -290,86 +159,51 @@ export async function getProctoringInfoData(courseId, username) {
} }
} }
export async function getLiveTabIframe(courseId) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
try { try {
const { data } = await getAuthenticatedHttpClient().get(url); tabData = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) { if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {}; return {};
} }
throw error; 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
let timeOffsetMillis = 0;
if (headerDate !== undefined) {
const headerTime = Date.parse(headerDate);
const roundTripMillis = requestTime - responseTime;
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
timeOffsetMillis = headerTime - localTime;
}
return timeOffsetMillis;
}
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 { const {
data, data,
headers,
} = tabData; } = tabData;
const accessExpiration = camelCaseObject(data.access_expiration); const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock; const canShowUpgradeSock = data.can_show_upgrade_sock;
const certData = camelCaseObject(data.cert_data);
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {}; const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals); const courseGoals = camelCaseObject(data.course_goals);
const courseTools = camelCaseObject(data.course_tools); const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info); const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget); const datesWidget = camelCaseObject(data.dates_widget);
const enableProctoredExams = data.enable_proctored_exams;
const enrollAlert = camelCaseObject(data.enroll_alert); const enrollAlert = camelCaseObject(data.enroll_alert);
const enrollmentMode = data.enrollment_mode;
const handoutsHtml = data.handouts_html; const handoutsHtml = data.handouts_html;
const hasScheduledContent = data.has_scheduled_content;
const hasEnded = data.has_ended; const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer); const offer = camelCaseObject(data.offer);
const resumeCourse = camelCaseObject(data.resume_course); 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 verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html || ''; const welcomeMessageHtml = data.welcome_message_html;
return { return {
accessExpiration, accessExpiration,
canShowUpgradeSock, canShowUpgradeSock,
certData,
courseBlocks, courseBlocks,
courseGoals, courseGoals,
courseTools, courseTools,
datesBannerInfo, datesBannerInfo,
datesWidget, datesWidget,
enrollAlert, enrollAlert,
enrollmentMode,
enableProctoredExams,
handoutsHtml, handoutsHtml,
hasScheduledContent,
hasEnded, hasEnded,
offer, offer,
resumeCourse, resumeCourse,
timeOffsetMillis, // This should move to a global time correction reference
userHasPassingGrade,
verifiedMode, verifiedMode,
welcomeMessageHtml, welcomeMessageHtml,
}; };
@@ -383,22 +217,13 @@ export async function postCourseDeadlines(courseId, model) {
}); });
} }
export async function deprecatedPostCourseGoals(courseId, goalKey) { export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`); 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 }); 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) { 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 }); await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
} }
@@ -414,9 +239,3 @@ export async function executePostFromPostEvent(postData, researchEventData) {
research_event_data: researchEventData, research_event_data: researchEventData,
}); });
} }
export async function unsubscribeFromCourseGoal(token) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}

View File

@@ -1,16 +0,0 @@
import { getTimeOffsetMillis } from './api';
describe('Calculate the time offset properly', () => {
it('Should return 0 if the headerDate is not set', async () => {
const offset = getTimeOffsetMillis(undefined, undefined, undefined);
expect(offset).toBe(0);
});
it('Should return the offset', async () => {
const headerDate = '2021-04-13T11:01:58.135Z';
const requestTime = new Date('2021-04-12T11:01:57.135Z');
const responseTime = new Date('2021-04-12T11:01:58.635Z');
const offset = getTimeOffsetMillis(headerDate, requestTime, responseTime);
expect(offset).toBe(86398750);
});
});

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => { describe('Data layer integration tests', () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata'); const courseHomeMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = 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); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
let store; let store;
@@ -31,7 +31,7 @@ describe('Data layer integration tests', () => {
}); });
describe('Test fetchDatesTab', () => { 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 () => { it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError(); axiosMock.onGet(courseMetadataUrl).networkError();
@@ -60,7 +60,7 @@ describe('Data layer integration tests', () => {
}); });
describe('Test fetchOutlineTab', () => { 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 () => { it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError(); axiosMock.onGet(courseMetadataUrl).networkError();
@@ -88,55 +88,12 @@ describe('Data layer integration tests', () => {
}); });
}); });
describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${progressBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const progressTabData = Factory.build('progressTabData', { courseId });
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(progressUrl).reply(200, progressTabData);
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
});
it('Should handle the url including a targetUserId', async () => {
const progressTabData = Factory.build('progressTabData', { courseId });
const targetUserId = 2;
const progressUrl = `${progressBaseUrl}/${courseId}/${targetUserId}/`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(progressUrl).reply(200, progressTabData);
await executeThunk(thunks.fetchProgressTab(courseId, 2), store.dispatch);
const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2);
});
});
describe('Test saveCourseGoal', () => { describe('Test saveCourseGoal', () => {
it('Should save course goal', async () => { 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, {}); 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].url).toEqual(goalUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`); expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
@@ -164,7 +121,7 @@ describe('Data layer integration tests', () => {
describe('Test dismissWelcomeMessage', () => { describe('Test dismissWelcomeMessage', () => {
it('Should dismiss welcome message', async () => { 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); axiosMock.onPost(dismissUrl).reply(201);
await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch); await executeThunk(thunks.dismissWelcomeMessage(courseId), store.dispatch);

View File

@@ -4,39 +4,29 @@ import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading'; export const LOADING = 'loading';
export const LOADED = 'loaded'; export const LOADED = 'loaded';
export const FAILED = 'failed'; export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({ const slice = createSlice({
name: 'course-home', name: 'course-home',
initialState: { initialState: {
courseStatus: 'loading', courseStatus: 'loading',
courseId: null, courseId: null,
proctoringPanelStatus: 'loading',
toastBodyText: null, toastBodyText: null,
toastBodyLink: null, toastBodyLink: null,
toastHeader: '', toastHeader: '',
}, },
reducers: { 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 }) => { fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId; state.courseId = payload.courseId;
state.courseStatus = LOADING; state.courseStatus = LOADING;
}, },
fetchTabSuccess: (state, { payload }) => { fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId; state.courseId = payload.courseId;
state.targetUserId = payload.targetUserId;
state.courseStatus = LOADED; state.courseStatus = LOADED;
}, },
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
setCallToActionToast: (state, { payload }) => { setCallToActionToast: (state, { payload }) => {
const { const {
header, header,
@@ -51,11 +41,9 @@ const slice = createSlice({
}); });
export const { export const {
fetchProctoringInfoResolved,
fetchTabDenied,
fetchTabFailure,
fetchTabRequest, fetchTabRequest,
fetchTabSuccess, fetchTabSuccess,
fetchTabFailure,
setCallToActionToast, setCallToActionToast,
} = slice.actions; } = slice.actions;

View File

@@ -7,11 +7,9 @@ import {
getOutlineTabData, getOutlineTabData,
getProgressTabData, getProgressTabData,
postCourseDeadlines, postCourseDeadlines,
deprecatedPostCourseGoals, postCourseGoals,
postWeeklyLearningGoal,
postDismissWelcomeMessage, postDismissWelcomeMessage,
postRequestCert, postRequestCert,
getLiveTabIframe,
} from './api'; } from './api';
import { import {
@@ -19,7 +17,6 @@ import {
} from '../../generic/model-store'; } from '../../generic/model-store';
import { import {
fetchTabDenied,
fetchTabFailure, fetchTabFailure,
fetchTabRequest, fetchTabRequest,
fetchTabSuccess, fetchTabSuccess,
@@ -30,41 +27,46 @@ const eventTypes = {
POST_EVENT: 'post_event', POST_EVENT: 'post_event',
}; };
export function fetchTab(courseId, tab, getTabData, targetUserId) { export function fetchTab(courseId, tab, getTabData) {
return async (dispatch) => { return async (dispatch) => {
dispatch(fetchTabRequest({ courseId })); dispatch(fetchTabRequest({ courseId }));
try { Promise.allSettled([
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline'); getCourseHomeCourseMetadata(courseId),
dispatch(addModel({ getTabData(courseId),
modelType: 'courseHomeMeta', ]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
model: { const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
id: courseId, const fetchedTabData = tabDataResult.status === 'fulfilled';
...courseHomeCourseMetadata,
}, if (fetchedCourseHomeCourseMetadata) {
})); dispatch(addModel({
const tabDataResult = getTabData && await getTabData(courseId, targetUserId); modelType: 'courseHomeMeta',
if (tabDataResult) { model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({ dispatch(addModel({
modelType: tab, modelType: tab,
model: { model: {
id: courseId, id: courseId,
...tabDataResult, ...tabDataResult.value,
}, },
})); }));
} else {
logError(tabDataResult.reason);
} }
// Disable the access-denied path for now - it caused a regression
if (!courseHomeCourseMetadata.courseAccess.hasAccess) { if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabDenied({ courseId })); dispatch(fetchTabSuccess({ courseId }));
} else if (tabDataResult || !getTabData) { } else {
dispatch(fetchTabSuccess({ dispatch(fetchTabFailure({ courseId }));
courseId,
targetUserId,
}));
} }
} catch (e) { });
dispatch(fetchTabFailure({ courseId }));
logError(e);
}
}; };
} }
@@ -72,22 +74,14 @@ export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData); return fetchTab(courseId, 'dates', getDatesTabData);
} }
export function fetchProgressTab(courseId, targetUserId) { export function fetchProgressTab(courseId) {
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId); return fetchTab(courseId, 'progress', getProgressTabData);
} }
export function fetchOutlineTab(courseId) { export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData); 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) { export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId); return async () => postDismissWelcomeMessage(courseId);
} }
@@ -111,12 +105,8 @@ export function resetDeadlines(courseId, model, getTabData) {
}; };
} }
export async function deprecatedSaveCourseGoal(courseId, goalKey) { export async function saveCourseGoal(courseId, goalKey) {
return deprecatedPostCourseGoals(courseId, goalKey); return postCourseGoals(courseId, goalKey);
}
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
} }
export function processEvent(eventData, getTabData) { export function processEvent(eventData, getTabData) {

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from './messages';
function DatesBanner(props) {
const {
intl,
name,
bannerClickHandler,
} = props;
return (
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-lg-9'}>
<strong>
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
</strong>
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
</div>
{bannerClickHandler && (
<div className="col-auto col-lg-3 p-lg-0 d-inline-flex align-items-center justify-content-start justify-content-lg-center">
<Button variant="outline-primary" className="align-self-center mt-3 mt-lg-0" onClick={bannerClickHandler}>
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
</Button>
</div>
)}
</div>
</div>
);
}
DatesBanner.propTypes = {
intl: intlShape.isRequired,
name: PropTypes.string.isRequired,
bannerClickHandler: PropTypes.func,
};
DatesBanner.defaultProps = {
bannerClickHandler: null,
};
export default injectIntl(DatesBanner);

View File

@@ -0,0 +1,100 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import DatesBanner from './DatesBanner';
import { resetDeadlines } from '../data';
function DatesBannerContainer({
courseDateBlocks,
datesBannerInfo,
hasEnded,
logUpgradeLinkClick,
model,
tabFetch,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
contentTypeGatingEnabled,
missedDeadlines,
missedGatedContent,
verifiedUpgradeLink,
} = datesBannerInfo;
const {
isSelfPaced,
} = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;
const upgradeToReset = !upgradeToCompleteGraded && missedDeadlines && missedGatedContent;
const resetDates = !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent;
const datesBanners = [
{
name: 'datesTabInfoBanner',
shouldDisplay: model === 'dates' && hasDeadlines && !missedDeadlines && isSelfPaced,
},
{
name: 'upgradeToCompleteGradedBanner',
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
shouldDisplay: upgradeToCompleteGraded && verifiedUpgradeLink,
clickHandler: () => {
logUpgradeLinkClick();
global.location.replace(verifiedUpgradeLink);
},
},
{
name: 'upgradeToResetBanner',
// verifiedUpgradeLink can be null if we've passed the upgrade deadline
shouldDisplay: upgradeToReset && verifiedUpgradeLink,
clickHandler: () => {
logUpgradeLinkClick();
global.location.replace(verifiedUpgradeLink);
},
},
{
name: 'resetDatesBanner',
shouldDisplay: resetDates,
clickHandler: () => dispatch(resetDeadlines(courseId, model, tabFetch)),
},
];
return (
<>
{!hasEnded && datesBanners.map((banner) => banner.shouldDisplay && (
<DatesBanner
name={banner.name}
bannerClickHandler={banner.clickHandler}
key={banner.name}
/>
))}
</>
);
}
DatesBannerContainer.propTypes = {
courseDateBlocks: PropTypes.arrayOf(PropTypes.object).isRequired,
datesBannerInfo: PropTypes.shape({
contentTypeGatingEnabled: PropTypes.bool.isRequired,
missedDeadlines: PropTypes.bool.isRequired,
missedGatedContent: PropTypes.bool.isRequired,
verifiedUpgradeLink: PropTypes.string,
}).isRequired,
hasEnded: PropTypes.bool,
logUpgradeLinkClick: PropTypes.func,
model: PropTypes.string.isRequired,
tabFetch: PropTypes.func.isRequired,
};
DatesBannerContainer.defaultProps = {
hasEnded: false,
logUpgradeLinkClick: () => {},
};
export default DatesBannerContainer;

View File

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

View File

@@ -0,0 +1,66 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'datesBanner.datesTabInfoBanner.header': {
id: 'datesBanner.datesTabInfoBanner.header',
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
description: 'Strong text in Dates Tab Info Banner',
},
'datesBanner.datesTabInfoBanner.body': {
id: 'datesBanner.datesTabInfoBanner.body',
defaultMessage: `But don't worry—it's flexible so you can learn at your own pace. If you happen to fall behind on
our suggested dates, you'll be able to adjust them to keep yourself on track.`,
description: 'Body in Dates Tab Info Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.header': {
id: 'datesBanner.upgradeToCompleteGradedBanner.header',
defaultMessage: 'You are auditing this course, ',
description: 'Strong text in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.body': {
id: 'datesBanner.upgradeToCompleteGradedBanner.body',
defaultMessage: `which means that you are unable to participate in graded assignments. To complete graded
assignments as part of this course, you can upgrade today.`,
description: 'Body in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToCompleteGradedBanner.button': {
id: 'datesBanner.upgradeToCompleteGradedBanner.button',
defaultMessage: 'Upgrade now',
description: 'Button in Upgrade To Complete Graded Banner',
},
'datesBanner.upgradeToResetBanner.header': {
id: 'datesBanner.upgradeToResetBanner.header',
defaultMessage: 'You are auditing this course, ',
description: 'Strong text in Upgrade To Reset Banner',
},
'datesBanner.upgradeToResetBanner.body': {
id: 'datesBanner.upgradeToResetBanner.body',
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
and shift the past due assignments into the future, you can upgrade today.`,
description: 'Body in Upgrade To Reset Banner',
},
'datesBanner.upgradeToResetBanner.button': {
id: 'datesBanner.upgradeToResetBanner.button',
defaultMessage: 'Upgrade to shift due dates',
description: 'Button in Upgrade To Reset Banner',
},
'datesBanner.resetDatesBanner.header': {
id: 'datesBanner.resetDatesBanner.header',
defaultMessage: 'It looks like you missed some important deadlines based on our suggested schedule. ',
description: 'Strong text in Reset Dates Banner',
},
'datesBanner.resetDatesBanner.body': {
id: 'datesBanner.resetDatesBanner.body',
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.`,
description: 'Body in Reset Dates Banner',
},
'datesBanner.resetDatesBanner.button': {
id: 'datesBanner.resetDatesBanner.button',
defaultMessage: 'Shift due dates',
description: 'Button in Reset Dates Banner',
},
});
export default messages;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function Badge({ children, className }) {
return (
<span
className={classNames('dates-badge small ml-2', className)}
data-testid="dates-badge"
>
{children}
</span>
);
}
Badge.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
Badge.defaultProps = {
children: null,
className: null,
};

View File

@@ -0,0 +1,4 @@
.dates-badge {
border-radius: 4px;
padding: 2px 8px 3px 8px;
}

View File

@@ -4,31 +4,32 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages'; import messages from './messages';
import Timeline from './timeline/Timeline'; import Timeline from './Timeline';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchDatesTab } from '../data'; import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store'; import { useModel } from '../../generic/model-store';
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader'; /** [MM-P2P] Experiment */
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert'; import { initDatesMMP2P } from '../../experiments/mm-p2p';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
const DatesTab = ({ intl }) => { function DatesTab({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
isSelfPaced,
org, org,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
courseDateBlocks, courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel('dates', courseId); } = useModel('dates', courseId);
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date'); /** [MM-P2P] Experiment */
const mmp2p = initDatesMMP2P(courseId);
const logUpgradeLinkClick = () => { const logUpgradeLinkClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', { sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
@@ -46,18 +47,21 @@ const DatesTab = ({ intl }) => {
<div role="heading" aria-level="1" className="h2 my-3"> <div role="heading" aria-level="1" className="h2 my-3">
{intl.formatMessage(messages.title)} {intl.formatMessage(messages.title)}
</div> </div>
{isSelfPaced && hasDeadlines && ( { /** [MM-P2P] Experiment */ }
<> { !mmp2p.state.isEnabled && (
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} /> <DatesBannerContainer
<SuggestedScheduleHeader /> courseDateBlocks={courseDateBlocks}
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} /> datesBannerInfo={datesBannerInfo}
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" /> hasEnded={hasEnded}
</> logUpgradeLinkClick={logUpgradeLinkClick}
)} model="dates"
<Timeline /> tabFetch={fetchDatesTab}
/>
) }
<Timeline mmp2p={mmp2p} />
</> </>
); );
}; }
DatesTab.propTypes = { DatesTab.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -23,37 +23,19 @@ jest.mock('@edx/frontend-platform/analytics');
describe('DatesTab', () => { describe('DatesTab', () => {
let axiosMock; let axiosMock;
let store;
let component;
beforeEach(() => { const store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); const component = (
store = initializeStore(); <AppProvider store={store}>
component = ( <UserMessagesProvider>
<AppProvider store={store}> <Route path="/course/:courseId/dates">
<UserMessagesProvider> <TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<Route path="/course/:courseId/dates"> <DatesTab />
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome"> </TabContainer>
<DatesTab /> </Route>
</TabContainer> </UserMessagesProvider>
</Route> </AppProvider>
</UserMessagesProvider> );
</AppProvider>
);
});
const datesTabData = Factory.build('datesTabData');
let courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
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}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {
courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow // The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or // testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
@@ -79,8 +61,15 @@ describe('DatesTab', () => {
describe('when receiving a full set of dates data', () => { describe('when receiving a full set of dates data', () => {
beforeEach(() => { beforeEach(() => {
const datesTabData = Factory.build('datesTabData');
const courseMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = courseMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
render(component); render(component);
@@ -140,30 +129,38 @@ describe('DatesTab', () => {
userEvent.hover(tipIcon); userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon); userEvent.unhover(tipIcon);
await waitForElementToBeRemoved(tooltip); // and it's gone again waitForElementToBeRemoved(tooltip); // and it's gone again
}); });
}); });
describe('Suggested schedule messaging', () => { describe('Dates banner container ', () => {
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
const { id: courseId } = courseMetadata;
const datesTabData = Factory.build('datesTabData');
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
beforeEach(() => { beforeEach(() => {
setMetadata({ is_self_paced: true, is_enrolled: true }); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
history.push(`/course/${courseId}/dates`); history.push(`/course/${courseId}/dates`);
}); });
it('renders SuggestedScheduleHeader', async () => { it('renders datesTabInfoBanner', async () => {
datesTabData.datesBannerInfo = { datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: false, contentTypeGatingEnabled: false,
missedDeadlines: false, missedDeadlines: false,
missedGatedContent: false, missedGatedContent: false,
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
await waitFor(() => expect(screen.getByText('Weve built a suggested schedule to help you stay on track. But dont worry—its flexible so you can learn at your own pace.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText("We've built a suggested schedule to help you stay on track.")).toBeInTheDocument());
}); });
it('renders UpgradeToCompleteAlert', async () => { it('renders upgradeToCompleteGradedBanner', async () => {
datesTabData.datesBannerInfo = { datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true, contentTypeGatingEnabled: true,
missedDeadlines: false, missedDeadlines: false,
@@ -171,14 +168,15 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5', verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
await waitFor(() => expect(screen.getByText('You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
expect(screen.getByText('which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
}); });
it('renders UpgradeToShiftDatesAlert', async () => { it('renders upgradeToResetBanner', async () => {
datesTabData.datesBannerInfo = { datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true, contentTypeGatingEnabled: true,
missedDeadlines: true, missedDeadlines: true,
@@ -186,15 +184,15 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5', verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument(); expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
}); });
it('renders ShiftDatesAlert', async () => { it('renders resetDatesBanner', async () => {
datesTabData.datesBannerInfo = { datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true, contentTypeGatingEnabled: true,
missedDeadlines: true, missedDeadlines: true,
@@ -202,7 +200,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5', verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
@@ -218,7 +216,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5', verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
// confirm "Shift due dates" button has rendered // confirm "Shift due dates" button has rendered
@@ -246,7 +244,7 @@ describe('DatesTab', () => {
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
}); });
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => { it('sends analytics event onClick of upgrade button in upgradeToCompleteGradedBanner', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
datesTabData.datesBannerInfo = { datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true, contentTypeGatingEnabled: true,
@@ -255,7 +253,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5', verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' })); const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
@@ -272,7 +270,7 @@ describe('DatesTab', () => {
}); });
}); });
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => { it('sends analytics event onClick of upgrade button in upgradeToResetBanner', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
datesTabData.datesBannerInfo = { datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true, contentTypeGatingEnabled: true,
@@ -281,7 +279,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5', verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
}; };
axiosMock.onGet(datesUrl).reply(200, datesTabData); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component); render(component);
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' })); const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
@@ -298,55 +296,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`);
});
});
}); });

View File

@@ -2,39 +2,41 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
FormattedDate,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@edx/paragon'; import { Tooltip, OverlayTrigger } from '@edx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../generic/model-store';
import { getBadgeListAndColor } from './badgelist'; import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from '../utils'; import { isLearnerAssignment } from './utils';
const Day = ({ function Day({
date, date,
first, first,
intl, intl,
items, items,
last, last,
}) => { /** [MM-P2P] Example */
mmp2p,
}) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
userTimezone, userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('dates', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items); const { color, badges } = getBadgeListAndColor(date, intl, null, items);
/** [MM-P2P] Experiment */
const mmp2pOverride = (
mmp2p.state.isEnabled
&& items.some((item) => item.dateType === 'verified-upgrade-deadline')
);
return ( return (
<li className="dates-day pb-4" data-testid="dates-day"> <li className="dates-day pb-4" data-testid="dates-day">
{/* Top Line */} {/* Top Line */}
@@ -48,41 +50,36 @@ const Day = ({
{/* Content */} {/* Content */}
<div className="d-inline-block ml-3 pl-2"> <div className="d-inline-block ml-3 pl-2">
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header"> <div className="mb-1" data-testid="dates-header">
<FormattedDate <p className="d-inline text-dark-500 font-weight-bold">
value={date} <FormattedDate
day="numeric" /** [MM-P2P] Experiment */
month="short" value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
weekday="short" day="numeric"
year="numeric" month="short"
{...timezoneFormatArgs} weekday="short"
/> year="numeric"
{...timezoneFormatArgs}
/>
</p>
{badges} {badges}
</div> </div>
{items.map((item) => { {items.map((item) => {
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items); /** [MM-P2P] Experiment (conditional) */
const { badges: itemBadges } = mmp2pOverride
? getBadgeListAndColor(new Date(mmp2p.state.upgradeDeadline), intl, item, items)
: getBadgeListAndColor(date, intl, item, items);
const showDueDateTime = item.dateType === 'assignment-due-date';
const showLink = item.link && isLearnerAssignment(item); const showLink = item.link && isLearnerAssignment(item);
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title; const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item)); const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-primary-700' : 'text-gray-500'; const textColor = available ? 'text-dark-500' : 'text-dark-200';
return ( return (
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item"> <div key={item.title + item.date} className={textColor} data-testid="dates-item">
<div> <div>
<span className="small"> <span className="font-weight-bold small mt-1">
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span> {item.assignmentType && `${item.assignmentType}: `}{title}
{showDueDateTime && (
<span>
<span className="mx-1">due</span>
<FormattedTime
value={date}
timeZoneName="short"
{...timezoneFormatArgs}
/>
</span>
)}
</span> </span>
{itemBadges} {itemBadges}
{item.extraInfo && ( {item.extraInfo && (
@@ -96,14 +93,22 @@ const Day = ({
</OverlayTrigger> </OverlayTrigger>
)} )}
</div> </div>
{item.description && <div className="small mb-2">{item.description}</div>} { /** [MM-P2P] Experiment (conditional) */ }
{ mmp2pOverride
? (
<div className="small mb-2">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
)
: (item.description && <div className="small mb-2">{item.description}</div>)}
</div> </div>
); );
})} })}
</div> </div>
</li> </li>
); );
}; }
Day.propTypes = { Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired, date: PropTypes.objectOf(Date).isRequired,
@@ -119,11 +124,25 @@ Day.propTypes = {
title: PropTypes.string, title: PropTypes.string,
})).isRequired, })).isRequired,
last: PropTypes.bool, last: PropTypes.bool,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
}; };
Day.defaultProps = { Day.defaultProps = {
first: false, first: false,
last: false, last: false,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
}; };
export default injectIntl(Day); export default injectIntl(Day);

View File

@@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
/** [MM-P2P] Experiment (import) */
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../generic/model-store';
import Day from './Day'; import Day from './Day';
import { daycmp, isLearnerAssignment } from '../utils'; import { daycmp, isLearnerAssignment } from './utils';
const Timeline = () => { /** [MM-P2P] Experiment (argument) */
export default function Timeline({ mmp2p }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -61,12 +64,19 @@ const Timeline = () => {
} }
return ( return (
<ul className="list-unstyled m-0 mt-4 pt-2"> <ul className="list-unstyled m-0">
{groupedDates.map((groupedDate) => ( {groupedDates.map((groupedDate) => (
<Day key={groupedDate.date} {...groupedDate} /> <Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
))} ))}
</ul> </ul>
); );
}
/** [MM-P2P] Experiment */
Timeline.propTypes = {
mmp2p: PropTypes.shape({}),
}; };
export default Timeline; Timeline.defaultProps = {
mmp2p: {},
};

View File

@@ -2,10 +2,10 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons'; import { faLock } from '@fortawesome/free-solid-svg-icons';
import { Badge } from '@edx/paragon';
import messages from '../messages'; import Badge from './Badge';
import { daycmp, isLearnerAssignment } from '../utils'; import messages from './messages';
import { daycmp, isLearnerAssignment } from './utils';
function hasAccess(item) { function hasAccess(item) {
return item.learnerHasAccess; return item.learnerHasAccess;
@@ -38,14 +38,14 @@ function getBadgeListAndColor(date, intl, item, items) {
message: messages.today, message: messages.today,
shownForDay: isToday, shownForDay: isToday,
bg: 'bg-warning-300', bg: 'bg-warning-300',
className: 'text-black', className: 'text-gray-900',
}, },
{ {
message: messages.completed, message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete), shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x), shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-light-500', bg: 'bg-dark-100',
className: 'text-black', className: 'text-gray-900',
}, },
{ {
message: messages.pastDue, message: messages.pastDue,
@@ -72,7 +72,7 @@ function getBadgeListAndColor(date, intl, item, items) {
shownForDay: items.length && items.every(x => !hasAccess(x)), shownForDay: items.length && items.every(x => !hasAccess(x)),
shownForItem: x => !hasAccess(x), shownForItem: x => !hasAccess(x),
icon: faLock, icon: faLock,
bg: 'bg-dark-700', bg: 'bg-dark-500',
className: 'text-white', className: 'text-white',
}, },
]; ];
@@ -96,7 +96,7 @@ function getBadgeListAndColor(date, intl, item, items) {
color = b.bg; color = b.bg;
} }
return ( return (
<Badge key={b.message.id} className={classNames('ml-2', b.bg, b.className)} data-testid="dates-badge"> <Badge key={b.message.id} className={classNames(b.bg, b.className)}>
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />} {b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
{intl.formatMessage(b.message)} {intl.formatMessage(b.message)}
</Badge> </Badge>

View File

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

View File

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

View File

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

View File

@@ -1,58 +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';
const 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 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // deps=[] to only run once
return (
<>
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
)}
{!isLoading && (
<ResultPage error={error} courseTitle={data.courseTitle} />
)}
</main>
</>
);
};
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,22 +0,0 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
const 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;

View File

@@ -9,10 +9,12 @@ import { useModel } from '../../generic/model-store';
import { isLearnerAssignment } from '../dates-tab/utils'; import { isLearnerAssignment } from '../dates-tab/utils';
import './DateSummary.scss'; import './DateSummary.scss';
const DateSummary = ({ export default function DateSummary({
dateBlock, dateBlock,
userTimezone, userTimezone,
}) => { /** [MM-P2P] Experiment */
mmp2p,
}) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -23,6 +25,9 @@ const DateSummary = ({
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock); const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
/** [MM-P2P] Experiment */
const showMMP2P = mmp2p.state.isEnabled && (dateBlock.dateType === 'verified-upgrade-deadline');
const logVerifiedUpgradeClick = () => { const logVerifiedUpgradeClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', { sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org, org_key: org,
@@ -35,12 +40,13 @@ const DateSummary = ({
}; };
return ( 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"> <div className="row">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth /> <FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
<div className="ml-1 font-weight-bold"> <div className="ml-1 font-weight-bold">
<FormattedDate <FormattedDate
value={dateBlock.date} /** [MM-P2P] Experiment */
value={showMMP2P ? mmp2p.state.upgradeDeadline : dateBlock.date}
day="numeric" day="numeric"
month="short" month="short"
weekday="short" weekday="short"
@@ -49,33 +55,48 @@ const DateSummary = ({
/> />
</div> </div>
</div> </div>
<div className="row ml-4 pr-2"> {/** [MM-P2P] Experiment (conditional) */}
<div className="date-summary-text"> { showMMP2P ? (
{linkedTitle && ( <div className="row ml-4 pr-2">
<div className="date-summary-text">
<div className="font-weight-bold mt-2"> <div className="font-weight-bold mt-2">
<a href={dateBlock.link}>{dateBlock.title}</a> Last chance to upgrade
</div> </div>
</div>
<div className="date-summary-text mt-1">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
</div>
) : (
<div className="row ml-4 pr-2">
<div className="date-summary-text">
{linkedTitle && (
<div className="font-weight-bold mt-2">
<a href={dateBlock.link}>{dateBlock.title}</a>
</div>
)}
{!linkedTitle && (
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
)}
</div>
{dateBlock.description && (
<div className="date-summary-text mt-1">{dateBlock.description}</div>
)} )}
{!linkedTitle && ( {!linkedTitle && dateBlock.link && (
<div className="font-weight-bold mt-2">{dateBlock.title}</div> <a
href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link"
>
{dateBlock.linkText}
</a>
)} )}
</div> </div>
{dateBlock.description && ( )}
<div className="date-summary-text mt-1">{dateBlock.description}</div>
)}
{!linkedTitle && dateBlock.link && (
<a
href={dateBlock.link}
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
className="description-link"
>
{dateBlock.linkText}
</a>
)}
</div>
</li> </li>
); );
}; }
DateSummary.propTypes = { DateSummary.propTypes = {
dateBlock: PropTypes.shape({ dateBlock: PropTypes.shape({
@@ -88,10 +109,22 @@ DateSummary.propTypes = {
learnerHasAccess: PropTypes.bool, learnerHasAccess: PropTypes.bool,
}).isRequired, }).isRequired,
userTimezone: PropTypes.string, userTimezone: PropTypes.string,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
}; };
DateSummary.defaultProps = { DateSummary.defaultProps = {
userTimezone: null, userTimezone: null,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
}; };
export default DateSummary;

View File

@@ -1,52 +1,34 @@
import React, { useEffect, useRef } from 'react'; import React, { useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
const LmsHtmlFragment = ({ export default function LmsHtmlFragment({
className, className,
html, html,
title, title,
...rest ...rest
}) => { }) {
const wholePage = ` const wholePage = `
<html> <html>
<head> <head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent"> <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}/static/LmsHtmlFragment.css"> <link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
</head> </head>
<body class="${className}">${html}</body> <body class="${className}">${html}</body>
<script>
const resizer = new ResizeObserver(() => {
window.parent.postMessage({type: 'lmshtmlfragment.resize'}, '*');
});
resizer.observe(document.body);
</script>
</html> </html>
`; `;
const iframe = useRef(null); const iframe = useRef(null);
function resetIframeHeight() { function handleLoad() {
if (iframe?.current?.contentWindow?.document?.body) { iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
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 ( return (
<iframe <iframe
className="w-100 border-0" className="w-100 border-0"
onLoad={resetIframeHeight} onLoad={handleLoad}
ref={iframe} ref={iframe}
referrerPolicy="origin" referrerPolicy="origin"
scrolling="no" scrolling="no"
@@ -55,7 +37,7 @@ const LmsHtmlFragment = ({
{...rest} {...rest}
/> />
); );
}; }
LmsHtmlFragment.defaultProps = { LmsHtmlFragment.defaultProps = {
className: '', className: '',
@@ -66,5 +48,3 @@ LmsHtmlFragment.propTypes = {
html: PropTypes.string.isRequired, html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
}; };
export default LmsHtmlFragment;

View File

@@ -1,71 +1,74 @@
import React, { useEffect, useState } from 'react'; import React, { useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { 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 { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates'; import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts'; import CourseHandouts from './widgets/CourseHandouts';
import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard'; import CourseSock from '../../generic/course-sock';
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools'; import CourseTools from './widgets/CourseTools';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchOutlineTab } from '../data'; import { fetchOutlineTab } from '../data';
import genericMessages from '../../generic/messages';
import messages from './messages'; import messages from './messages';
import Section from './Section'; import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert'; import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification'; import UpgradeCard from './widgets/UpgradeCard';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert'; import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert'; import useCertificateAvailableAlert from './alerts/certificate-available-alert';
import useCourseEndAlert from './alerts/course-end-alert'; import useCourseEndAlert from './alerts/course-end-alert';
import useCourseStartAlert from '../../alerts/course-start-alert'; import useCourseStartAlert from './alerts/course-start-alert';
import useOfferAlert from '../../alerts/offer-alert';
import usePrivateCourseAlert from './alerts/private-course-alert'; import usePrivateCourseAlert from './alerts/private-course-alert';
import useScheduledContentAlert from './alerts/scheduled-content-alert';
import { useModel } from '../../generic/model-store'; import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage'; import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel'; import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
const OutlineTab = ({ intl }) => { /** [MM-P2P] Experiment */
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const { const {
courseId, courseId,
proctoringPanelStatus,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
isSelfPaced,
org, org,
title, title,
userTimezone, username,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
accessExpiration, accessExpiration,
canShowUpgradeSock,
courseBlocks: { courseBlocks: {
courses, courses,
sections, sections,
}, },
courseGoals: { courseGoals: {
goalOptions,
selectedGoal, selectedGoal,
weeklyLearningGoalEnabled, },
} = {},
datesBannerInfo, datesBannerInfo,
datesWidget: { datesWidget: {
courseDateBlocks, courseDateBlocks,
userTimezone,
},
hasEnded,
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
}, },
enableProctoredExams,
offer, offer,
timeOffsetMillis,
verifiedMode, verifiedMode,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const { const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
marketingUrl, const [goalToastHeader, setGoalToastHeader] = useState('');
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false); const [expandAll, setExpandAll] = useState(false);
const eventProperties = { const eventProperties = {
@@ -73,18 +76,27 @@ const OutlineTab = ({ intl }) => {
courserun_key: courseId, 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) // Below the course title alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(courseId, offer, org, userTimezone, 'outline-course-alerts', 'course_home');
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, 'outline-course-alerts', 'course_home');
const courseStartAlert = useCourseStartAlert(courseId); const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId); const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId); const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const privateCourseAlert = usePrivateCourseAlert(courseId); const privateCourseAlert = usePrivateCourseAlert(courseId);
const scheduledContentAlert = useScheduledContentAlert(courseId);
const rootCourseId = courses && Object.keys(courses)[0]; const rootCourseId = courses && Object.keys(courses)[0];
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date'); const courseSock = useRef(null);
const logUpgradeToShiftDatesLinkClick = () => { const logUpgradeLinkClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', { sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties, ...eventProperties,
linkCategory: 'personalized_learner_schedules', linkCategory: 'personalized_learner_schedules',
@@ -94,42 +106,32 @@ const OutlineTab = ({ intl }) => {
}); });
}; };
const isEnterpriseUser = () => { /** [[MM-P2P] Experiment */
const authenticatedUser = getAuthenticatedUser(); const MMP2P = initHomeMMP2P(courseId);
const userRoleNames = authenticatedUser ? authenticatedUser.roles.map(role => role.split(':')[0]) : [];
return userRoleNames.includes('enterprise_learner');
};
/** 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('enrollment.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 ( 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 m-0 mb-3 justify-content-between">
<div className="col-12 col-sm-auto p-0"> <div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div> <div role="heading" aria-level="1" className="h2">{title}</div>
</div> </div>
{resumeCourseUrl && (
<div className="col-12 col-sm-auto p-0">
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
</div>
)}
</div> </div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab"> <div className="row course-outline-tab">
<AccountActivationAlert />
<div className="col-12"> <div className="col-12">
<AlertList <AlertList
topic="outline-private-alerts" topic="outline-private-alerts"
@@ -139,34 +141,53 @@ const OutlineTab = ({ intl }) => {
/> />
</div> </div>
<div className="col col-12 col-md-8"> <div className="col col-12 col-md-8">
<AlertList { /** [MM-P2P] Experiment (the conditional) */ }
topic="outline-course-alerts" { !MMP2P.state.isEnabled
className="mb-3" && (
customAlerts={{ <AlertList
...certificateAvailableAlert, topic="outline-course-alerts"
...courseEndAlert, className="mb-3"
...courseStartAlert, customAlerts={{
...scheduledContentAlert, ...accessExpirationAlert,
}} ...certificateAvailableAlert,
/> ...courseEndAlert,
{isSelfPaced && hasDeadlines && ( ...courseStartAlert,
<> ...offerAlert,
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} /> }}
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} /> />
</> )}
{courseDateBlocks && (
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
logUpgradeLinkClick={logUpgradeLinkClick}
model="outline"
tabFetch={fetchOutlineTab}
/** [MM-P2P] Experiment */
isMMP2PEnabled={MMP2P.state.isEnabled}
/>
)}
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<CourseGoalCard
courseId={courseId}
goalOptions={goalOptions}
title={title}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)} )}
<StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} /> <WelcomeMessage courseId={courseId} />
{rootCourseId && ( {rootCourseId && (
<> <>
<div className="row w-100 m-0 mb-3 justify-content-end"> <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); }}> <Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)} {expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button> </Button>
</div> </div>
</div> </div>
<ol id="courseHome-outline" className="list-unstyled"> <ol className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => ( {courses[rootCourseId].sectionIds.map((sectionId) => (
<Section <Section
key={sectionId} key={sectionId}
@@ -182,37 +203,57 @@ const OutlineTab = ({ intl }) => {
</div> </div>
{rootCourseId && ( {rootCourseId && (
<div className="col col-12 col-md-4"> <div className="col col-12 col-md-4">
<ProctoringInfoPanel /> <ProctoringInfoPanel
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as courseId={courseId}
disabled to avoid components bouncing around too much as screen is rendered */ } username={username}
{(!enableProctoredExams || proctoringPanelStatus === 'loaded') && weeklyLearningGoalEnabled && ( />
<WeeklyLearningGoalCard {courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null} <UpdateGoalSelector
subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false} courseId={courseId}
goalOptions={goalOptions}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/> />
)} )}
<CourseTools /> <CourseTools
<UpgradeNotification courseId={courseId}
offer={offer} />
verifiedMode={verifiedMode} { /** [MM-P2P] Experiment (conditional) */ }
accessExpiration={accessExpiration} { MMP2P.state.isEnabled
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled} ? <MMP2PFlyover isStatic options={MMP2P} />
marketingUrl={marketingUrl} : (
upsellPageName="course_home" <UpgradeCard
userTimezone={userTimezone} courseId={courseId}
shouldDisplayBorder onLearnMore={
timeOffsetMillis={timeOffsetMillis} canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null
}
/>
)}
<CourseDates
courseId={courseId}
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CourseHandouts
courseId={courseId} courseId={courseId}
org={org}
/> />
<CourseDates />
<CourseHandouts />
</div> </div>
)} )}
</div> </div>
{canShowUpgradeSock && (
<CourseSock
courseId={courseId}
offer={offer}
orgKey={org}
pageLocation="Home Page"
ref={courseSock}
verifiedMode={verifiedMode}
/>
)}
</> </>
); );
}; }
OutlineTab.propTypes = { OutlineTab.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -1,16 +1,10 @@
/**
* @jest-environment jsdom
*/
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import messages from './messages';
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory'; import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
import { import {
@@ -19,11 +13,7 @@ import {
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils'; import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks'; import * as thunks from '../data/thunks';
import initializeStore from '../../store'; import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab'; import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
import SequenceLink from './SequenceLink';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
@@ -31,21 +21,20 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => { describe('Outline Tab', () => {
let axiosMock; let axiosMock;
const courseId = 'course-v1:edX+DemoX+Demo_Course'; const courseId = 'course-v1:edX+Test+run';
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); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
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`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(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 store = initializeStore(); const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata'); const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('outlineTabData'); const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) { 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); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
} }
@@ -54,14 +43,9 @@ describe('Outline Tab', () => {
axiosMock.onGet(outlineUrl).reply(200, outlineTabData); axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
} }
async function fetchAndRender(path = '') { async function fetchAndRender() {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render( await act(async () => render(<OutlineTab />, { store }));
<MemoryRouter initialEntries={[path]}>
<OutlineTab />
</MemoryRouter>,
{ store },
));
} }
beforeEach(async () => { beforeEach(async () => {
@@ -71,7 +55,6 @@ describe('Outline Tab', () => {
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {}); axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' }); axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData); axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, { axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'created', onboarding_status: 'created',
@@ -85,7 +68,7 @@ describe('Outline Tab', () => {
describe('Course Outline', () => { describe('Course Outline', () => {
it('displays link to start course', async () => { it('displays link to start course', async () => {
await fetchAndRender(); 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 () => { it('displays link to resume course', async () => {
@@ -146,8 +129,25 @@ describe('Outline Tab', () => {
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument(); 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 }); 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({ setTabData({
course_blocks: { blocks: courseBlocks.blocks }, course_blocks: { blocks: courseBlocks.blocks },
}); });
@@ -156,54 +156,11 @@ describe('Outline Tab', () => {
const sequenceLink = screen.getByText('Title of Sequence'); const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`); expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`);
}); });
it('if exam due date set, exam description AND due date appear', async () => {
// Create a due date set a year into the future
const now = new Date();
const dueDate = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
// Build course blocks with a future due date set
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', {
sequenceBlocks: [
<SequenceLink
key={0}
id={0}
courseId={courseId}
sequence={
{
complete: false,
description: 'Description of Sequence',
due: dueDate,
showLink: true,
title: 'Title of Subsection',
}
}
first={null}
/>,
],
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
expect(expandButton).toBeInTheDocument();
// Click to expand section
userEvent.click(expandButton);
// Look for a substring that says "(exam type) Exam due (datetime)""
expect(screen.getByText(/Exam due/)).toBeInDocument();
});
// If due date is NOT set, ONLY display description
}); });
describe('Suggested schedule alerts', () => { describe('Dates Banner', () => {
beforeEach(() => { beforeEach(() => {
setMetadata({ is_enrolled: true, is_self_paced: true }); setMetadata({ is_enrolled: true });
setTabData({ setTabData({
dates_banner_info: { dates_banner_info: {
content_type_gating_enabled: true, content_type_gating_enabled: true,
@@ -226,15 +183,15 @@ describe('Outline Tab', () => {
}); });
}); });
it('renders UpgradeToShiftDatesAlert', async () => { it('renders upgradeToReset', async () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument(); expect(screen.getByText('You are auditing this course,')).toBeInTheDocument();
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument(); expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
}); });
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => { it('sends analytics event onClick of upgrade button in banner', async () => {
await fetchAndRender(); await fetchAndRender();
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
@@ -324,13 +281,13 @@ describe('Outline Tab', () => {
], ],
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Important dates' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Upcoming Dates' })).toBeInTheDocument();
}); });
it('does not render when course date blocks are not populated', async () => { it('does not render when course date blocks are not populated', async () => {
setMetadata({ is_enrolled: true }); setMetadata({ is_enrolled: true });
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Important dates' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Upcoming Dates' })).not.toBeInTheDocument();
}); });
it('sends analytics event onClick of upgrade link', async () => { it('sends analytics event onClick of upgrade link', async () => {
@@ -366,164 +323,88 @@ describe('Outline Tab', () => {
}); });
}); });
describe('Start or Resume Course Card', () => { describe('Course Goals', () => {
it('renders startOrResumeCourseCard', async () => { 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(); await fetchAndRender();
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument(); expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
}); expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
}); expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
describe('Weekly Learning Goal', () => {
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);
}); });
it('post goal via query param', async () => { describe('goal is not set', () => {
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('enrollment.email.clicked.setgoal', {});
});
it('emit start course event via query param', async () => {
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?start_course=1');
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.startcourse', {});
});
describe('weekly learning goal is not set', () => {
beforeEach(async () => { beforeEach(async () => {
setTabData({ setTabData({
course_goals: { course_goals: {
weekly_learning_goal_enabled: true, goal_options: goalOptions,
selected_goal: null,
}, },
}); });
await fetchAndRender(); await fetchAndRender();
}); });
it('renders weekly learning goal card', async () => { it('renders goal card', () => {
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument(); 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 () => { it('renders goal selector on goal selection', async () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled(); const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
}); fireEvent.click(certifyGoalButton);
it.each([ const goalSelector = await screen.findByTestId('edit-goal-selector');
{ level: 'Casual', days: 1 }, expect(goalSelector).toBeInTheDocument();
{ level: 'Regular', days: 3 },
{ level: 'Intense', days: 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);
// 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(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 () => { describe('goal is set', () => {
setTabData({ beforeEach(async () => {
course_goals: { setTabData({
weekly_learning_goal_enabled: true, course_goals: {
selected_goal: { goal_options: goalOptions,
subscribed_to_reminders: true, selected_goal: { text: 'Earn a certificate', key: 'certify' },
days_per_week: 3,
}, },
}, });
await fetchAndRender();
}); });
await fetchAndRender();
const button = await screen.queryByTestId('weekly-learning-goal-input-Regular'); it('renders edit goal selector', () => {
expect(button).toBeInTheDocument(); expect(screen.getByLabelText('Goal')).toBeInTheDocument();
expect(button).toHaveClass('flag-button-selected'); expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
}); expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
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 () => { it('updates goal on click', async () => {
setTabData({ // Open dropdown
course_goals: { const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
weekly_learning_goal_enabled: true, await waitFor(() => {
enableProctoredExams: false, expect(dropdownButtonNode).toBeInTheDocument();
}, });
}); fireEvent.click(dropdownButtonNode);
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is enabled', async () => { // Select a new goal
setTabData({ const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
course_goals: { await waitFor(() => {
weekly_learning_goal_enabled: true, expect(unsureButtonNode).toBeInTheDocument();
enableProctoredExams: true, });
}, fireEvent.click(unsureButtonNode);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
}); });
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
}); });
}); });
@@ -554,6 +435,35 @@ describe('Outline Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument(); 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', () => { describe('Alert List', () => {
@@ -573,8 +483,8 @@ describe('Outline Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert'); const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert).toHaveAttribute('role', 'alert'); expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument(); expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
@@ -583,8 +493,8 @@ describe('Outline Tab', () => {
it('displays alert for unenrolled user', async () => { it('displays alert for unenrolled user', async () => {
await fetchAndRender(); await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert'); const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert).toHaveAttribute('role', 'alert'); expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
}); });
@@ -609,35 +519,70 @@ describe('Outline Tab', () => {
}); });
describe('Access Expiration Alert', () => { describe('Access Expiration Alert', () => {
it('renders page banner on masquerade', async () => { it('has special masquerade text', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({ setTabData({
access_expiration: { access_expiration: {
expiration_date: '2020-01-01T12:00:00Z', expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true, masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
}, },
}); });
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await fetchAndRender();
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store })); await screen.findByText('This learner does not have access to this course.', { exact: false });
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();
}); });
it('does not render banner when not masquerading', async () => { it('shows expiration', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({ setTabData({
access_expiration: { access_expiration: {
expiration_date: '2020-01-01T12:00:00Z', expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false, masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
}, },
}); });
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await fetchAndRender();
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store })); await screen.findByText('Audit Access Expires');
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(); it('shows upgrade prompt', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
});
it('sends analytics event onClick of upgrade link', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
sendTrackEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'FBE_banner',
linkName: 'course_home_audit_access_expires',
linkType: 'link',
pageName: 'course_home',
});
}); });
}); });
@@ -646,7 +591,16 @@ describe('Outline Tab', () => {
it('appears several days out', async () => { it('appears several days out', async () => {
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() + 100); 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(); await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false }); const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
@@ -655,7 +609,16 @@ describe('Outline Tab', () => {
it('appears today', async () => { it('appears today', async () => {
const startDate = new Date(); const startDate = new Date();
startDate.setHours(startDate.getHours() + 1); 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(); await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false }); const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
@@ -708,280 +671,71 @@ describe('Outline Tab', () => {
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true }); setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
});
await fetchAndRender();
await screen.findByText('We are working on generating course certificates.');
});
});
describe('Offer Alert', () => {
it('sends analytics event onClick of upgrade link', async () => {
setTabData({ setTabData({
cert_data: { offer: {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE, code: 'EDXWELCOME',
cert_web_view_url: null, expiration_date: '2070-01-01T12:00:00Z',
certificate_available_date: tomorrow.toISOString(), original_price: '$100',
discounted_price: '$85',
percentage: 15,
upgrade_url: 'https://example.com/upgrade',
}, },
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
}); });
it('renders verification alert', async () => {
const now = new Date(); it('sends analytics event onClick of upgrade link', async () => {
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({ setTabData({
cert_data: { offer: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED, code: 'EDXWELCOME',
cert_web_view_url: null, expiration_date: '2070-01-01T12:00:00Z',
original_price: '$100',
discounted_price: '$85',
percentage: 15,
upgrade_url: 'https://example.com/upgrade',
}, },
}, {
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(); await fetchAndRender();
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
}); // Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
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();
});
it('tracks request cert button', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
const now = new Date(); const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); fireEvent.click(upgradeLink);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_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: 'Request certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1); expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith( expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked', org_key: 'edX',
{ courserun_key: courseId,
courserun_key: courseId, linkCategory: 'welcome',
is_staff: false, linkName: 'course_home_welcome',
org_key: 'edX', linkType: 'link',
}, pageName: 'course_home',
);
});
it('tracks unverified 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.UNVERIFIED,
cert_web_view_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('link', { name: 'Verify my ID' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
},
);
}); });
}); });
describe('Scheduled Content Alert', () => {
it('appears correctly', async () => {
const now = new Date();
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { hasScheduledContent: true });
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
date_blocks: [
{
date_type: 'course-end-date',
date: tomorrow.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('More content is coming soon!')).toBeInTheDocument();
});
});
describe('Scheduled Content Alert not present without courseBlocks', () => {
it('appears correctly', async () => {
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
course_blocks: null,
date_blocks: [
{
date_type: 'course-end-date',
date: tomorrow.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
});
});
});
describe('Certificate (web) 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: 'certificate/testuuid',
certificate_available_date: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
});
});
describe('Requesting Certificate 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.REQUESTING,
cert_web_view_url: null,
certificate_available_date: null,
},
}, {
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.queryByText('Request certificate')).toBeInTheDocument();
});
}); });
describe('Proctoring Info Panel', () => { describe('Proctoring Info Panel', () => {
@@ -1061,27 +815,11 @@ describe('Outline Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
await screen.findByText('This course contains proctored exams'); await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding exam has been approved in another course.')).toBeInTheDocument(); expect(screen.queryByText('You are eligible to take proctored exams in this course.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument(); expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
}); });
it('displays expiration warning', async () => { 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(); const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future // This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000); expirationDate.setTime(expirationDate.getTime() + 864800000);
@@ -1093,23 +831,7 @@ describe('Outline Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
await screen.findByText('This course contains proctored exams'); 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('Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this 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();
});
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('Onboarding profile review can take 2+ business days.')).toBeInTheDocument(); expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
}); });
@@ -1130,7 +852,6 @@ describe('Outline Tab', () => {
it('does not appear for 404', async () => { it('does not appear for 404', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(404); axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
}); });
@@ -1243,51 +964,4 @@ describe('Outline Tab', () => {
}); });
}); });
}); });
describe('Account Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({
observe: () => null,
disconnect: () => null,
});
window.IntersectionObserver = jest.fn().mockImplementation(intersectionObserverMock);
});
it('displays account activation alert if cookie is set true', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
expect(screen.queryByText('Activate your account so you can log back in')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'resend the email' })).toBeInTheDocument();
});
it('do not displays account activation alert if cookie is not set true', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn();
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
expect(screen.queryByText('Activate your account so you can log back in')).not.toBeInTheDocument();
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 () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const resendEmailUrl = `${getConfig().LMS_BASE_URL}/api/send_account_activation_email`;
axiosMock.onPost(resendEmailUrl).reply(200, {});
const resendLink = screen.getByRole('button', { name: 'resend the email' });
fireEvent.click(resendLink);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].url).toEqual(resendEmailUrl);
});
});
}); });

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