Compare commits
7 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21abde463 | ||
|
|
37d9646629 | ||
|
|
ad72980ad7 | ||
|
|
71bcb6ba62 | ||
|
|
da867d0ef6 | ||
|
|
131096b4a5 | ||
|
|
76e83cc737 |
15
.env
15
.env
@@ -2,21 +2,14 @@
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
INSIGHTS_BASE_URL=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
||||
@@ -26,15 +19,12 @@ LOGOUT_URL=''
|
||||
LOGO_URL=''
|
||||
LOGO_TRADEMARK_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LEGACY_THEME_NAME=''
|
||||
FAVICON_URL=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
SEGMENT_KEY=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
SITE_NAME=''
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
|
||||
STUDIO_BASE_URL=''
|
||||
@@ -46,3 +36,6 @@ TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='development'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -25,12 +18,10 @@ LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LEGACY_THEME_NAME=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
@@ -46,3 +37,5 @@ TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
13
.env.test
13
.env.test
@@ -2,21 +2,14 @@
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -25,12 +18,10 @@ LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LEGACY_THEME_NAME=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
@@ -45,3 +36,5 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
22
.eslintrc.js
22
.eslintrc.js
@@ -1,17 +1,11 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('eslint', {
|
||||
rules: {
|
||||
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release.
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': '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 = createConfig('eslint', {
|
||||
overrides: [{
|
||||
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
|
||||
rules: {
|
||||
'import/named': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -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 }}
|
||||
@@ -1,20 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -1,13 +0,0 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
12
.github/workflows/self-assign-issue.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
12
.github/workflows/update-browserslist-db.yml
vendored
@@ -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 }}
|
||||
24
.github/workflows/validate.yml
vendored
24
.github/workflows/validate.yml
vendored
@@ -1,23 +1,21 @@
|
||||
name: validate
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
- push
|
||||
- pull_request
|
||||
jobs:
|
||||
tests:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- 12
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
1
.husky/_/.gitignore
vendored
1
.husky/_/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
@@ -1,9 +1,8 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-learning]
|
||||
[edx-platform.frontend-app-learning]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
type = KEYVALUEJSON
|
||||
|
||||
15
Makefile
15
Makefile
@@ -1,9 +1,11 @@
|
||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
||||
transifex_resource = frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
@@ -36,15 +38,15 @@ push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
$(transifex_utils) $(transifex_temp) --comments
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -58,6 +60,7 @@ validate:
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run is-es5
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
20
README.rst
20
README.rst
@@ -15,7 +15,7 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
|
||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
||||
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
Development
|
||||
-----------
|
||||
@@ -23,7 +23,7 @@ Development
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
- Run ``make dev.up.lms``
|
||||
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
@@ -52,7 +52,7 @@ 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
|
||||
----------
|
||||
@@ -71,15 +71,6 @@ as documented in the Open edX Developer Guide under
|
||||
|
||||
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.
|
||||
@@ -119,3 +110,8 @@ TWITTER_URL
|
||||
|
||||
Example: https://twitter.com/edXOnline
|
||||
|
||||
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
|
||||
|
||||
@@ -9,6 +9,4 @@ module.exports = createConfig('jest', {
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
testTimeout: 30000,
|
||||
testEnvironment: 'jsdom'
|
||||
});
|
||||
|
||||
41089
package-lock.json
generated
41089
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@@ -4,14 +4,16 @@
|
||||
"description": "Frontend learning application.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-learning.git"
|
||||
"url": "git+https://github.com/edx/frontend-app-learning.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"prepare": "husky install",
|
||||
@@ -21,60 +23,61 @@
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/frontend-component-footer": "^12.0.0",
|
||||
"@edx/frontend-component-header": "^4.0.0",
|
||||
"@edx/frontend-lib-special-exams": "^2.16.1",
|
||||
"@edx/frontend-platform": "^4.2.0",
|
||||
"@edx/paragon": "20.28.4",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.1.6",
|
||||
"@edx/frontend-enterprise-utils": "1.1.1",
|
||||
"@edx/frontend-lib-special-exams": "1.14.1",
|
||||
"@edx/frontend-platform": "1.14.3",
|
||||
"@edx/paragon": "16.19.0",
|
||||
"@edx/frontend-component-header": "^2.4.3",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.18",
|
||||
"@popperjs/core": "2.11.6",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "5.3.0",
|
||||
"@fortawesome/react-fontawesome": "0.1.15",
|
||||
"@pact-foundation/pact": "9.16.4",
|
||||
"@reduxjs/toolkit": "1.6.2",
|
||||
"classnames": "2.3.1",
|
||||
"core-js": "3.18.3",
|
||||
"js-cookie": "3.0.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "7.1.3",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-break": "1.3.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-redux": "7.2.5",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.7",
|
||||
"react-share": "4.4.0",
|
||||
"redux": "4.1.1",
|
||||
"regenerator-runtime": "0.13.9",
|
||||
"reselect": "4.0.0",
|
||||
"truncate-html": "1.0.4",
|
||||
"util": "0.12.5"
|
||||
"util": "0.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.1.1",
|
||||
"@edx/frontend-build": "^12.8.27",
|
||||
"@edx/reactifex": "2.1.1",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"@edx/frontend-build": "9.0.5",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.14.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@testing-library/user-event": "13.4.1",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"es-check": "6.2.1",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.5.0",
|
||||
"codecov": "3.8.3",
|
||||
"es-check": "6.0.0",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.2",
|
||||
"jest": "27.2.5",
|
||||
"jest-chain": "1.1.5",
|
||||
"reactifex": "1.1.1",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,12 +5,5 @@
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
"rebaseStalePrs": true
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
@@ -7,8 +8,18 @@ import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
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 {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
@@ -28,6 +39,13 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
upgradeUrl,
|
||||
} = accessExpiration;
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
if (showMMP2P) {
|
||||
return (
|
||||
<AccessExpirationAlertMMP2P payload={payload} />
|
||||
);
|
||||
}
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
@@ -47,7 +65,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.deadline"
|
||||
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
|
||||
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
@@ -80,7 +97,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
defaultMessage="Audit Access Expires {date}"
|
||||
description="Headline for auditing deadline"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
@@ -99,7 +115,6 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.body"
|
||||
defaultMessage="You lose all access to this course, including your progress, on {date}."
|
||||
description="Message body to tell learner the consequences of course expiration."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
@@ -116,7 +131,7 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
||||
{deadlineMessage}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
import 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.
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
>
|
||||
{messages.upgradeNow.defaultMessage}
|
||||
</Hyperlink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" icon={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);
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@edx/paragon';
|
||||
|
||||
const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
||||
function AccessExpirationMasqueradeBanner({ payload }) {
|
||||
const {
|
||||
expirationDate,
|
||||
userTimezone,
|
||||
@@ -16,7 +16,6 @@ const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
||||
<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"
|
||||
@@ -27,7 +26,7 @@ const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
||||
/>
|
||||
</PageBanner>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
AccessExpirationMasqueradeBanner.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
|
||||
@@ -7,17 +7,17 @@ const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpira
|
||||
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic,
|
||||
});
|
||||
|
||||
@@ -34,14 +34,14 @@ export function useAccessExpirationMasqueradeBanner(courseId, tab) {
|
||||
|
||||
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
|
||||
const expirationDate = accessExpiration && accessExpiration.expirationDate;
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
expirationDate,
|
||||
userTimezone,
|
||||
}), [expirationDate, userTimezone]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationMasqueradeBanner',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.accessExpiration.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'The anchor text for the upgrading link',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import genericMessages from './messages';
|
||||
|
||||
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);
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import useActiveEnterpriseAlert from './hooks';
|
||||
|
||||
export default useActiveEnterpriseAlert;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
changeActiveEnterpriseLowercase: {
|
||||
id: 'learning.activeEnterprise.change.alert',
|
||||
defaultMessage: 'change enterprise now',
|
||||
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelativeTime,
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
@@ -11,11 +11,9 @@ import { Info } from '@edx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
const CourseStartAlert = ({ payload }) => {
|
||||
function CourseStartAlert({ payload }) {
|
||||
const {
|
||||
courseId,
|
||||
} = payload;
|
||||
@@ -27,17 +25,15 @@ const CourseStartAlert = ({ payload }) => {
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
const timeRemaining = (
|
||||
<FormattedRelativeTime
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={delta / 1000}
|
||||
numeric="auto"
|
||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
||||
updateIntervalInSeconds={YEAR_SEC}
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
@@ -90,11 +86,10 @@ const CourseStartAlert = ({ payload }) => {
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.end.calendar"
|
||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
||||
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseStartAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PageBanner } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const CourseStartMasqueradeBanner = ({ payload }) => {
|
||||
function CourseStartMasqueradeBanner({ payload }) {
|
||||
const {
|
||||
courseId,
|
||||
} = payload;
|
||||
@@ -22,7 +22,6 @@ const CourseStartMasqueradeBanner = ({ payload }) => {
|
||||
<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"
|
||||
@@ -33,7 +32,7 @@ const CourseStartMasqueradeBanner = ({ payload }) => {
|
||||
/>
|
||||
</PageBanner>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseStartMasqueradeBanner.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useModel } from '../../generic/model-store';
|
||||
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
||||
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
|
||||
|
||||
function IsStartDateInFuture(courseId) {
|
||||
function isStartDateInFuture(courseId) {
|
||||
const {
|
||||
start,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
@@ -20,15 +20,15 @@ function useCourseStartAlert(courseId) {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
|
||||
const isVisible = isEnrolled && isStartDateInFuture(courseId);
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
courseId,
|
||||
}), [courseId]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartAlert',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
@@ -42,15 +42,15 @@ export function useCourseStartMasqueradeBanner(courseId, tab) {
|
||||
isMasquerading,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId);
|
||||
const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
courseId,
|
||||
}), [courseId]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartMasqueradeBanner',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
import useEnrollClickHandler from './clickHook';
|
||||
|
||||
const EnrollmentAlert = ({ intl, payload }) => {
|
||||
function EnrollmentAlert({ intl, payload }) {
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
@@ -55,7 +55,7 @@ const EnrollmentAlert = ({ intl, payload }) => {
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -27,7 +27,7 @@ function useEnrollClickHandler(courseId, orgId, successText) {
|
||||
});
|
||||
global.location.reload();
|
||||
});
|
||||
}, [addFlash, courseId, orgId, successText]);
|
||||
}, [courseId]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
@@ -22,16 +22,16 @@ export function useEnrollmentAlert(courseId) {
|
||||
* 3. the course is private.
|
||||
*/
|
||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||
courseId,
|
||||
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
||||
isStaff: course && course.isStaff,
|
||||
}), [course, courseId, outline]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientEnrollmentAlert',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
|
||||
@@ -9,13 +9,10 @@ import {
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import { Check, ArrowForward } from '@edx/paragon/icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { sendActivationEmail } from '../../courseware/data';
|
||||
import messages from './messages';
|
||||
|
||||
const AccountActivationAlert = ({
|
||||
intl,
|
||||
}) => {
|
||||
function AccountActivationAlert() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
@@ -32,12 +29,22 @@ const AccountActivationAlert = ({
|
||||
if (showAccountActivationAlert !== undefined) {
|
||||
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
|
||||
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
|
||||
// of cookie would make it infinite rendering
|
||||
// of cookie would make it infinit rendering
|
||||
if (Cookies.get('show-account-activation-popup') === undefined) {
|
||||
setShowModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
const title = (
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.title"
|
||||
defaultMessage="Activate your account so you can log back in"
|
||||
description="Title for account activation alert which is shown after the registration"
|
||||
/>
|
||||
</h3>
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -57,7 +64,7 @@ const AccountActivationAlert = ({
|
||||
);
|
||||
|
||||
const children = () => {
|
||||
let bodyContent;
|
||||
let bodyContent = null;
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.message"
|
||||
@@ -116,17 +123,13 @@ const AccountActivationAlert = ({
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={showModal}
|
||||
title={intl.formatMessage(messages.accountActivationAlertTitle)}
|
||||
title={title}
|
||||
footerNode={button}
|
||||
onClose={() => ({})}
|
||||
>
|
||||
{children()}
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
AccountActivationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
}
|
||||
|
||||
export default injectIntl(AccountActivationAlert);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
const LogistrationAlert = ({ intl }) => {
|
||||
function LogistrationAlert({ intl }) {
|
||||
const signIn = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
@@ -41,7 +41,7 @@ const LogistrationAlert = ({ intl }) => {
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
LogistrationAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
accountActivationAlertTitle: {
|
||||
id: 'account-activation.alert.title',
|
||||
defaultMessage: 'Activate your account so you can log back in',
|
||||
description: 'Title for account activation alert which is shown after the registration',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,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 };
|
||||
@@ -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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
@@ -8,9 +9,7 @@ Factory.define('courseHomeMetadata')
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
is_staff: false,
|
||||
can_view_certificate: true,
|
||||
celebrations: null,
|
||||
can_load_courseware: false,
|
||||
course_access: {
|
||||
additional_context_user_message: null,
|
||||
developer_message: null,
|
||||
@@ -19,104 +18,6 @@ Factory.define('courseHomeMetadata')
|
||||
user_fragment: null,
|
||||
user_message: null,
|
||||
},
|
||||
number: 'DemoX',
|
||||
original_user_is_staff: false,
|
||||
org: 'edX',
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
user_timezone: 'UTC',
|
||||
username: 'MockUser',
|
||||
verified_mode: {
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
sku: '8CF08E5',
|
||||
price: 149,
|
||||
currency_symbol: '$',
|
||||
},
|
||||
})
|
||||
.attr('tabs', ['id', 'host'], (id, host) => [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'discussion/forum/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course_wiki',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'progress',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'instructor',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Dates',
|
||||
priority: 5,
|
||||
slug: 'dates',
|
||||
type: 'dates',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'dates',
|
||||
},
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -35,13 +35,11 @@ Factory.define('outlineTabData')
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
selected_goal: null,
|
||||
weekly_learning_goal_enabled: false,
|
||||
days_per_week: null,
|
||||
subscribed_to_reminders: null,
|
||||
},
|
||||
course_tools: [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,6 @@ Factory.define('progressTabData')
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
credit_course_requirements: null,
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
|
||||
@@ -5,6 +5,7 @@ Factory.define('upgradeNotificationData')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.option('accessExpiration', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('upsellPageName', 'test')
|
||||
@@ -17,9 +18,4 @@ Factory.define('upgradeNotificationData')
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attrs({
|
||||
accessExpiration: {
|
||||
expiration_date: '1950-07-13T02:04:49.040006Z',
|
||||
},
|
||||
})
|
||||
.attr('timeOffsetMillis', 0);
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -15,14 +14,12 @@ 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,
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
@@ -31,7 +28,7 @@ Object {
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
@@ -44,49 +41,45 @@ Object {
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dates": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"courseDateBlocks": Array [
|
||||
Object {
|
||||
"date": "2020-05-01T17:59:41Z",
|
||||
@@ -300,7 +293,7 @@ Object {
|
||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
"hasEnded": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"learnerIsFullAccess": true,
|
||||
},
|
||||
},
|
||||
@@ -308,22 +301,14 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -333,14 +318,12 @@ 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,
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
@@ -349,7 +332,7 @@ Object {
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
@@ -362,61 +345,58 @@ Object {
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"outline": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"accessExpiration": null,
|
||||
"canShowUpgradeSock": false,
|
||||
"certData": Object {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
"downloadUrl": null,
|
||||
},
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionIds": Array [
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
],
|
||||
@@ -426,7 +406,7 @@ Object {
|
||||
"sections": Object {
|
||||
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
|
||||
"complete": false,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"resumeBlock": false,
|
||||
"sequenceIds": Array [
|
||||
@@ -444,6 +424,7 @@ Object {
|
||||
"effortTime": 15,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "Title of Sequence",
|
||||
@@ -451,11 +432,8 @@ Object {
|
||||
},
|
||||
},
|
||||
"courseGoals": Object {
|
||||
"daysPerWeek": null,
|
||||
"goalOptions": Array [],
|
||||
"selectedGoal": null,
|
||||
"subscribedToReminders": null,
|
||||
"weeklyLearningGoalEnabled": false,
|
||||
},
|
||||
"courseTools": Array [
|
||||
Object {
|
||||
@@ -472,7 +450,6 @@ Object {
|
||||
"datesWidget": Object {
|
||||
"courseDateBlocks": Array [],
|
||||
},
|
||||
"enableProctoredExams": undefined,
|
||||
"enrollAlert": Object {
|
||||
"canEnroll": true,
|
||||
"extraText": "Contact the administrator.",
|
||||
@@ -481,7 +458,7 @@ Object {
|
||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||
"hasEnded": undefined,
|
||||
"hasScheduledContent": null,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"offer": null,
|
||||
"resumeCourse": Object {
|
||||
"hasVisitedCourse": false,
|
||||
@@ -504,22 +481,14 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
|
||||
Object {
|
||||
"courseHome": Object {
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"courseStatus": "loaded",
|
||||
"proctoringPanelStatus": "loading",
|
||||
"targetUserId": undefined,
|
||||
"toastBodyLink": null,
|
||||
"toastBodyText": null,
|
||||
@@ -529,14 +498,12 @@ 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,
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"canLoadCourseware": false,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
"developerMessage": null,
|
||||
@@ -545,7 +512,7 @@ Object {
|
||||
"userFragment": null,
|
||||
"userMessage": null,
|
||||
},
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"isEnrolled": false,
|
||||
"isMasquerading": false,
|
||||
"isSelfPaced": false,
|
||||
@@ -558,49 +525,45 @@ Object {
|
||||
Object {
|
||||
"slug": "outline",
|
||||
"title": "Course",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||
},
|
||||
Object {
|
||||
"slug": "discussion",
|
||||
"title": "Discussion",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
|
||||
},
|
||||
Object {
|
||||
"slug": "wiki",
|
||||
"title": "Wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
|
||||
},
|
||||
Object {
|
||||
"slug": "progress",
|
||||
"title": "Progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
|
||||
},
|
||||
Object {
|
||||
"slug": "instructor",
|
||||
"title": "Instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||
},
|
||||
Object {
|
||||
"slug": "dates",
|
||||
"title": "Dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates",
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
||||
},
|
||||
],
|
||||
"title": "Demonstration Course",
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
"progress": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||
"accessExpiration": null,
|
||||
"certificateData": Object {},
|
||||
"completionSummary": Object {
|
||||
@@ -612,9 +575,9 @@ Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
"visiblePercent": 1,
|
||||
},
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"creditCourseRequirements": null,
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"end": "3027-03-31T00:00:00Z",
|
||||
"enrollmentMode": "audit",
|
||||
"gradesFeatureIsFullyLocked": false,
|
||||
@@ -622,7 +585,7 @@ Object {
|
||||
"gradingPolicy": Object {
|
||||
"assignmentPolicies": Array [
|
||||
Object {
|
||||
"averageGrade": "1.00",
|
||||
"averageGrade": 1,
|
||||
"numDroppable": 1,
|
||||
"shortLabel": "HW",
|
||||
"type": "Homework",
|
||||
@@ -635,7 +598,7 @@ Object {
|
||||
},
|
||||
},
|
||||
"hasScheduledContent": false,
|
||||
"id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||
"sectionScores": Array [
|
||||
Object {
|
||||
"displayName": "First section",
|
||||
@@ -706,12 +669,5 @@ Object {
|
||||
"recommendations": Object {
|
||||
"recommendationsStatus": "loading",
|
||||
},
|
||||
"tours": Object {
|
||||
"showCoursewareTour": false,
|
||||
"showExistingUserCourseHomeTour": false,
|
||||
"showNewUserCourseHomeModal": false,
|
||||
"showNewUserCourseHomeTour": false,
|
||||
"toursEnabled": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -15,10 +15,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
|
||||
averageGrade = points.reduce((a, b) => a + b, 0) / points.length;
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
@@ -90,21 +87,14 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
||||
* @returns {Object} The normalized metadata
|
||||
*/
|
||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
||||
// If needed, we switch it to "outline" here for
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
|
||||
// use within the MFE to differentiate between course home and courseware.
|
||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
||||
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
@@ -148,9 +138,12 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
effortTime: block.effort_time,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
|
||||
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
||||
showLink: !!block.lms_web_url,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
// The presence of an legacy URL for the sequence indicates that we want this
|
||||
// sequence to be a clickable link in the outline (even though, if the new
|
||||
// courseware experience is active, we will ignore `legacyWebUrl` and build a
|
||||
// link to the MFE ourselves).
|
||||
showLink: !!block.legacy_web_url,
|
||||
title: block.display_name,
|
||||
};
|
||||
break;
|
||||
@@ -186,11 +179,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
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}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
@@ -205,6 +198,10 @@ export async function getDatesTabData(courseId) {
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
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.
|
||||
@@ -232,6 +229,16 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
|
||||
// assignmentPolicies have been filtered by what's visible to the learner.
|
||||
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
|
||||
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
|
||||
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
|
||||
) : camelCasedData.courseGrade.percent;
|
||||
|
||||
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
|
||||
>= Math.min(...Object.values(data.grading_policy.grade_range));
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
@@ -290,20 +297,6 @@ export async function getProctoringInfoData(courseId, username) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLiveTabIframe(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||
@@ -321,9 +314,21 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const requestTime = Date.now();
|
||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
||||
const responseTime = Date.now();
|
||||
let { tabData } = {};
|
||||
let requestTime = Date.now();
|
||||
let responseTime = requestTime;
|
||||
try {
|
||||
requestTime = Date.now();
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
responseTime = Date.now();
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -338,7 +343,6 @@ export async function getOutlineTabData(courseId) {
|
||||
const courseTools = camelCaseObject(data.course_tools);
|
||||
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
||||
const datesWidget = camelCaseObject(data.dates_widget);
|
||||
const enableProctoredExams = data.enable_proctored_exams;
|
||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||
const enrollmentMode = data.enrollment_mode;
|
||||
const handoutsHtml = data.handouts_html;
|
||||
@@ -349,7 +353,7 @@ export async function getOutlineTabData(courseId) {
|
||||
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
||||
const userHasPassingGrade = data.user_has_passing_grade;
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html || '';
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
@@ -362,7 +366,6 @@ export async function getOutlineTabData(courseId) {
|
||||
datesWidget,
|
||||
enrollAlert,
|
||||
enrollmentMode,
|
||||
enableProctoredExams,
|
||||
handoutsHtml,
|
||||
hasScheduledContent,
|
||||
hasEnded,
|
||||
@@ -383,20 +386,11 @@ 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`);
|
||||
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
|
||||
}
|
||||
|
||||
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
|
||||
return getAuthenticatedHttpClient().post(url.href, {
|
||||
course_id: courseId,
|
||||
days_per_week: daysPerWeek,
|
||||
subscribed_to_reminders: subscribedToReminders,
|
||||
});
|
||||
}
|
||||
|
||||
export async function postDismissWelcomeMessage(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
|
||||
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
|
||||
|
||||
@@ -3,8 +3,7 @@ export {
|
||||
fetchOutlineTab,
|
||||
fetchProgressTab,
|
||||
resetDeadlines,
|
||||
deprecatedSaveCourseGoal,
|
||||
saveWeeklyLearningGoal,
|
||||
saveCourseGoal,
|
||||
} from './thunks';
|
||||
|
||||
export { reducer } from './slice';
|
||||
|
||||
@@ -39,186 +39,185 @@ describe('Course Home Service', () => {
|
||||
afterAll(() => provider.finalize());
|
||||
describe('When a request to fetch tab is made', () => {
|
||||
it('returns tab data for a course_id', async () => {
|
||||
setTimeout(() => {
|
||||
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'),
|
||||
await provider.addInteraction({
|
||||
state: `Tab data exists for course_id ${courseId}`,
|
||||
uponReceiving: 'a request to fetch tab',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: `/api/course_home/course_metadata/${courseId}`,
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
can_show_upgrade_sock: boolean(false),
|
||||
verified_mode: like({
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
can_load_courseware: boolean(true),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
streak_discount_enabled: false,
|
||||
}),
|
||||
course_access: {
|
||||
has_access: boolean(true),
|
||||
error_code: null,
|
||||
developer_message: null,
|
||||
user_message: null,
|
||||
additional_context_user_message: null,
|
||||
user_fragment: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
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',
|
||||
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: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
}, 100);
|
||||
}),
|
||||
title: string('Demonstration Course'),
|
||||
username: string('edx'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const normalizedTabData = {
|
||||
canShowUpgradeSock: false,
|
||||
verifiedMode: {
|
||||
accessExpirationDate: null,
|
||||
currency: 'USD',
|
||||
currencySymbol: '$',
|
||||
price: 149,
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
canLoadCourseware: true,
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
streakDiscountEnabled: false,
|
||||
},
|
||||
courseAccess: {
|
||||
hasAccess: true,
|
||||
errorCode: null,
|
||||
developerMessage: null,
|
||||
userMessage: null,
|
||||
additionalContextUserMessage: null,
|
||||
userFragment: null,
|
||||
},
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
isEnrolled: true,
|
||||
isMasquerading: false,
|
||||
isSelfPaced: false,
|
||||
isStaff: true,
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
originalUserIsStaff: true,
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
tabs: [
|
||||
{
|
||||
slug: 'outline',
|
||||
title: 'Course',
|
||||
url: `${getConfig().BASE_URL}/course/course-v1:edX+DemoX+Demo_Course/home`,
|
||||
},
|
||||
],
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = await getCourseHomeCourseMetadata(courseId);
|
||||
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 () => {
|
||||
setTimeout(() => {
|
||||
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,
|
||||
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: '2013-02-05T05:00:00Z',
|
||||
dateType: 'verified-upgrade-deadline',
|
||||
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.',
|
||||
learnerHasAccess: true,
|
||||
learner_has_access: true,
|
||||
link: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
linkText: 'Upgrade to Verified Certificate',
|
||||
link_text: 'Upgrade to Verified Certificate',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
extraInfo: null,
|
||||
firstComponentBlockId: '',
|
||||
},
|
||||
],
|
||||
hasEnded: false,
|
||||
learnerIsFullAccess: true,
|
||||
userTimezone: null,
|
||||
};
|
||||
const response = getDatesTabData(courseId);
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(camelCaseResponse);
|
||||
}, 100);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('Data layer integration tests', () => {
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
axiosMock.onPost(goalUrl).reply(200, {});
|
||||
|
||||
await thunks.deprecatedSaveCourseGoal(courseId, 'unsure');
|
||||
await thunks.saveCourseGoal(courseId, 'unsure');
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
|
||||
@@ -11,15 +11,11 @@ const slice = createSlice({
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
proctoringPanelStatus: 'loading',
|
||||
toastBodyText: null,
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
state.proctoringPanelStatus = LOADED;
|
||||
},
|
||||
fetchTabDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = DENIED;
|
||||
@@ -51,7 +47,6 @@ const slice = createSlice({
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchProctoringInfoResolved,
|
||||
fetchTabDenied,
|
||||
fetchTabFailure,
|
||||
fetchTabRequest,
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
deprecatedPostCourseGoals,
|
||||
postWeeklyLearningGoal,
|
||||
postCourseGoals,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
@@ -33,38 +31,46 @@ const eventTypes = {
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadata,
|
||||
},
|
||||
}));
|
||||
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
|
||||
if (tabDataResult) {
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(courseHomeCourseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedTabData) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult,
|
||||
...tabDataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (tabDataResult || !getTabData) {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
targetUserId,
|
||||
}));
|
||||
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
logError(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,14 +86,6 @@ export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function fetchLiveTab(courseId) {
|
||||
return fetchTab(courseId, 'live', getLiveTabIframe);
|
||||
}
|
||||
|
||||
export function fetchDiscussionTab(courseId) {
|
||||
return fetchTab(courseId, 'discussion');
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
@@ -111,12 +109,8 @@ export function resetDeadlines(courseId, model, getTabData) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function deprecatedSaveCourseGoal(courseId, goalKey) {
|
||||
return deprecatedPostCourseGoals(courseId, goalKey);
|
||||
}
|
||||
|
||||
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) {
|
||||
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders);
|
||||
export async function saveCourseGoal(courseId, goalKey) {
|
||||
return postCourseGoals(courseId, goalKey);
|
||||
}
|
||||
|
||||
export function processEvent(eventData, getTabData) {
|
||||
|
||||
@@ -9,12 +9,14 @@ import Timeline from './timeline/Timeline';
|
||||
import { fetchDatesTab } from '../data';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { initDatesMMP2P } from '../../experiments/mm-p2p';
|
||||
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
|
||||
const DatesTab = ({ intl }) => {
|
||||
function DatesTab({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -28,6 +30,9 @@ const DatesTab = ({ intl }) => {
|
||||
courseDateBlocks,
|
||||
} = useModel('dates', courseId);
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const mmp2p = initDatesMMP2P(courseId);
|
||||
|
||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||
|
||||
const logUpgradeLinkClick = () => {
|
||||
@@ -46,7 +51,8 @@ const DatesTab = ({ intl }) => {
|
||||
<div role="heading" aria-level="1" className="h2 my-3">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</div>
|
||||
{isSelfPaced && hasDeadlines && (
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||
<SuggestedScheduleHeader />
|
||||
@@ -54,10 +60,10 @@ const DatesTab = ({ intl }) => {
|
||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||
</>
|
||||
)}
|
||||
<Timeline />
|
||||
<Timeline mmp2p={mmp2p} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
DatesTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('DatesTab', () => {
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('DatesTab', () => {
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,12 +341,12 @@ describe('DatesTab', () => {
|
||||
|
||||
it('redirects to the home page when unauthenticated', async () => {
|
||||
await renderDenied('authentication_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('redirects to the home page when unenrolled', async () => {
|
||||
await renderDenied('enrollment_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,37 +4,30 @@ const messages = defineMessages({
|
||||
completed: {
|
||||
id: 'learning.dates.badge.completed',
|
||||
defaultMessage: 'Completed',
|
||||
description: 'shown as label for the assignments which learner has completed.',
|
||||
},
|
||||
dueNext: {
|
||||
id: 'learning.dates.badge.dueNext',
|
||||
defaultMessage: 'Due next',
|
||||
description: 'Shown as label for the assignment which date is in the future',
|
||||
},
|
||||
pastDue: {
|
||||
id: 'learning.dates.badge.pastDue',
|
||||
defaultMessage: 'Past due',
|
||||
description: 'Shown as label for the assignments which deadline has passed',
|
||||
},
|
||||
title: {
|
||||
id: 'learning.dates.title',
|
||||
defaultMessage: 'Important dates',
|
||||
description: 'The title of dates tab (course timeline).',
|
||||
},
|
||||
today: {
|
||||
id: 'learning.dates.badge.today',
|
||||
defaultMessage: 'Today',
|
||||
description: 'Label used when the scheduled date for the assignment matches the current day',
|
||||
},
|
||||
unreleased: {
|
||||
id: 'learning.dates.badge.unreleased',
|
||||
defaultMessage: 'Not yet released',
|
||||
description: 'Shown as label for assignments which date is unknown yet',
|
||||
},
|
||||
verifiedOnly: {
|
||||
id: 'learning.dates.badge.verifiedOnly',
|
||||
defaultMessage: 'Verified only',
|
||||
description: 'Shown as label for assignments which learner has no access to.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,13 +17,15 @@ import { useModel } from '../../../generic/model-store';
|
||||
import { getBadgeListAndColor } from './badgelist';
|
||||
import { isLearnerAssignment } from '../utils';
|
||||
|
||||
const Day = ({
|
||||
function Day({
|
||||
date,
|
||||
first,
|
||||
intl,
|
||||
items,
|
||||
last,
|
||||
}) => {
|
||||
/** [MM-P2P] Example */
|
||||
mmp2p,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -35,6 +37,11 @@ const Day = ({
|
||||
|
||||
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 (
|
||||
<li className="dates-day pb-4" data-testid="dates-day">
|
||||
{/* Top Line */}
|
||||
@@ -50,7 +57,8 @@ const Day = ({
|
||||
<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">
|
||||
<FormattedDate
|
||||
value={date}
|
||||
/** [MM-P2P] Experiment */
|
||||
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
@@ -60,7 +68,10 @@ const Day = ({
|
||||
{badges}
|
||||
</div>
|
||||
{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);
|
||||
@@ -96,14 +107,22 @@ const Day = ({
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</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!
|
||||
Unlock full course access and highlight the knowledge you'll gain.
|
||||
</div>
|
||||
)
|
||||
: (item.description && <div className="small mb-2">{item.description}</div>)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Day.propTypes = {
|
||||
date: PropTypes.objectOf(Date).isRequired,
|
||||
@@ -119,11 +138,25 @@ Day.propTypes = {
|
||||
title: PropTypes.string,
|
||||
})).isRequired,
|
||||
last: PropTypes.bool,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
isEnabled: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
Day.defaultProps = {
|
||||
first: false,
|
||||
last: false,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
state: {
|
||||
isEnabled: false,
|
||||
upgradeDeadline: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default injectIntl(Day);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
/** [MM-P2P] Experiment (import) */
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
@@ -6,7 +8,8 @@ import { useModel } from '../../../generic/model-store';
|
||||
import Day from './Day';
|
||||
import { daycmp, isLearnerAssignment } from '../utils';
|
||||
|
||||
const Timeline = () => {
|
||||
/** [MM-P2P] Experiment (argument) */
|
||||
export default function Timeline({ mmp2p }) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -63,10 +66,17 @@ const Timeline = () => {
|
||||
return (
|
||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||
{groupedDates.map((groupedDate) => (
|
||||
<Day key={groupedDate.date} {...groupedDate} />
|
||||
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
Timeline.propTypes = {
|
||||
mmp2p: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
Timeline.defaultProps = {
|
||||
mmp2p: {},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -1,61 +0,0 @@
|
||||
import { getConfig, history } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||
import {
|
||||
initializeMockApp, messageEvent, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { TabContainer } from '../../tab-page';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
import { fetchDiscussionTab } from '../data/thunks';
|
||||
import DiscussionTab from './DiscussionTab';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('DiscussionTab', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/discussion">
|
||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
||||
<DiscussionTab />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
|
||||
const { id: courseId } = courseMetadata;
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
|
||||
it('resizes when it gets a size hint from iframe', async () => {
|
||||
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
|
||||
await waitFor(() => expect(screen.getByTitle('discussion'))
|
||||
.toHaveAttribute('height', String(1234)));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -10,7 +9,7 @@ import { unsubscribeFromCourseGoal } from '../data/api';
|
||||
import messages from './messages';
|
||||
import ResultPage from './ResultPage';
|
||||
|
||||
const GoalUnsubscribe = ({ intl }) => {
|
||||
function GoalUnsubscribe({ intl }) {
|
||||
const { token } = useParams();
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -29,11 +28,6 @@ const GoalUnsubscribe = ({ intl }) => {
|
||||
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 (
|
||||
@@ -49,7 +43,7 @@ const GoalUnsubscribe = ({ intl }) => {
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
GoalUnsubscribe.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Hyperlink } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
|
||||
|
||||
const ResultPage = ({ courseTitle, error, intl }) => {
|
||||
function ResultPage({ courseTitle, error, intl }) {
|
||||
const errorDescription = (
|
||||
<FormattedMessage
|
||||
id="learning.goals.unsubscribe.errorDescription"
|
||||
@@ -36,15 +36,13 @@ const ResultPage = ({ courseTitle, error, intl }) => {
|
||||
<>
|
||||
<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>
|
||||
<div>{description}</div>
|
||||
<Button variant="brand" href={`${getConfig().LMS_BASE_URL}/dashboard`} className="mt-4">
|
||||
{intl.formatMessage(messages.goToDashboard)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
ResultPage.defaultProps = {
|
||||
courseTitle: null,
|
||||
|
||||
@@ -4,32 +4,26 @@ const messages = defineMessages({
|
||||
contactSupport: {
|
||||
id: 'learning.goals.unsubscribe.contact',
|
||||
defaultMessage: 'contact support',
|
||||
description: 'Its shown as a suggestion or recommendation for learner when their unsubscribing request has failed',
|
||||
},
|
||||
description: {
|
||||
id: 'learning.goals.unsubscribe.description',
|
||||
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.',
|
||||
description: 'It describes the consequences to learner when they unsubscribe of goal reminder service',
|
||||
},
|
||||
errorHeader: {
|
||||
id: 'learning.goals.unsubscribe.errorHeader',
|
||||
defaultMessage: 'Something went wrong',
|
||||
description: 'It indicate that the unsubscribing request has failed',
|
||||
},
|
||||
goToDashboard: {
|
||||
id: 'learning.goals.unsubscribe.goToDashboard',
|
||||
defaultMessage: 'Go to dashboard',
|
||||
description: 'Anchor text for button that redirects to dashboard page',
|
||||
},
|
||||
header: {
|
||||
id: 'learning.goals.unsubscribe.header',
|
||||
defaultMessage: 'You’ve unsubscribed from goal reminders',
|
||||
description: 'It indicate that the unsubscribing request was successful',
|
||||
},
|
||||
loading: {
|
||||
id: 'learning.goals.unsubscribe.loading',
|
||||
defaultMessage: 'Unsubscribing…',
|
||||
description: 'Message shown when the unsubscribing request is processing',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -9,10 +9,12 @@ import { useModel } from '../../generic/model-store';
|
||||
import { isLearnerAssignment } from '../dates-tab/utils';
|
||||
import './DateSummary.scss';
|
||||
|
||||
const DateSummary = ({
|
||||
export default function DateSummary({
|
||||
dateBlock,
|
||||
userTimezone,
|
||||
}) => {
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -23,6 +25,9 @@ const DateSummary = ({
|
||||
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const showMMP2P = mmp2p.state.isEnabled && (dateBlock.dateType === 'verified-upgrade-deadline');
|
||||
|
||||
const logVerifiedUpgradeClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
@@ -35,12 +40,13 @@ const DateSummary = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="p-0 mb-3 small text-dark-500">
|
||||
<li className="container p-0 mb-3 small text-dark-500">
|
||||
<div className="row">
|
||||
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
||||
<div className="ml-1 font-weight-bold">
|
||||
<FormattedDate
|
||||
value={dateBlock.date}
|
||||
/** [MM-P2P] Experiment */
|
||||
value={showMMP2P ? mmp2p.state.upgradeDeadline : dateBlock.date}
|
||||
day="numeric"
|
||||
month="short"
|
||||
weekday="short"
|
||||
@@ -49,33 +55,48 @@ const DateSummary = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row ml-4 pr-2">
|
||||
<div className="date-summary-text">
|
||||
{linkedTitle && (
|
||||
{/** [MM-P2P] Experiment (conditional) */}
|
||||
{ showMMP2P ? (
|
||||
<div className="row ml-4 pr-2">
|
||||
<div className="date-summary-text">
|
||||
<div className="font-weight-bold mt-2">
|
||||
<a href={dateBlock.link}>{dateBlock.title}</a>
|
||||
Last chance to upgrade
|
||||
</div>
|
||||
</div>
|
||||
<div className="date-summary-text mt-1">
|
||||
You are still eligible to upgrade to a Verified Certificate!
|
||||
Unlock full course access and highlight the knowledge you'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 && (
|
||||
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
|
||||
{!linkedTitle && dateBlock.link && (
|
||||
<a
|
||||
href={dateBlock.link}
|
||||
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
|
||||
className="description-link"
|
||||
>
|
||||
{dateBlock.linkText}
|
||||
</a>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
DateSummary.propTypes = {
|
||||
dateBlock: PropTypes.shape({
|
||||
@@ -88,10 +109,22 @@ DateSummary.propTypes = {
|
||||
learnerHasAccess: PropTypes.bool,
|
||||
}).isRequired,
|
||||
userTimezone: PropTypes.string,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
isEnabled: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
DateSummary.defaultProps = {
|
||||
userTimezone: null,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
state: {
|
||||
isEnabled: false,
|
||||
upgradeDeadline: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default DateSummary;
|
||||
|
||||
@@ -1,53 +1,34 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const LmsHtmlFragment = ({
|
||||
export default function LmsHtmlFragment({
|
||||
className,
|
||||
html,
|
||||
title,
|
||||
...rest
|
||||
}) => {
|
||||
const direction = document.documentElement?.getAttribute('dir') || 'ltr';
|
||||
}) {
|
||||
const wholePage = `
|
||||
<html dir="${direction}">
|
||||
<html>
|
||||
<head>
|
||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
||||
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/static/LmsHtmlFragment.css">
|
||||
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
|
||||
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
|
||||
</head>
|
||||
<body class="${className}">${html}</body>
|
||||
<script>
|
||||
const resizer = new ResizeObserver(() => {
|
||||
window.parent.postMessage({type: 'lmshtmlfragment.resize'}, '*');
|
||||
});
|
||||
resizer.observe(document.body);
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const iframe = useRef(null);
|
||||
function resetIframeHeight() {
|
||||
if (iframe?.current?.contentWindow?.document?.body) {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
function handleLoad() {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function receiveMessage(event) {
|
||||
const { type } = event.data;
|
||||
if (type === 'lmshtmlfragment.resize') {
|
||||
resetIframeHeight();
|
||||
}
|
||||
}
|
||||
global.addEventListener('message', receiveMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="w-100 border-0"
|
||||
onLoad={resetIframeHeight}
|
||||
onLoad={handleLoad}
|
||||
ref={iframe}
|
||||
referrerPolicy="origin"
|
||||
scrolling="no"
|
||||
@@ -56,7 +37,7 @@ const LmsHtmlFragment = ({
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
LmsHtmlFragment.defaultProps = {
|
||||
className: '',
|
||||
@@ -67,5 +48,3 @@ LmsHtmlFragment.propTypes = {
|
||||
html: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default LmsHtmlFragment;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { 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 { Button } from '@edx/paragon';
|
||||
|
||||
import { Button, Toast } from '@edx/paragon';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
import CourseGoalCard from './widgets/CourseGoalCard';
|
||||
import CourseHandouts from './widgets/CourseHandouts';
|
||||
import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard';
|
||||
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
|
||||
import CourseTools from './widgets/CourseTools';
|
||||
import { fetchOutlineTab } from '../data';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
import Section from './Section';
|
||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
|
||||
@@ -29,16 +29,19 @@ import WelcomeMessage from './widgets/WelcomeMessage';
|
||||
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 {
|
||||
courseId,
|
||||
proctoringPanelStatus,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isSelfPaced,
|
||||
org,
|
||||
title,
|
||||
username,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
@@ -49,23 +52,24 @@ const OutlineTab = ({ intl }) => {
|
||||
sections,
|
||||
},
|
||||
courseGoals: {
|
||||
goalOptions,
|
||||
selectedGoal,
|
||||
weeklyLearningGoalEnabled,
|
||||
} = {},
|
||||
datesBannerInfo,
|
||||
datesWidget: {
|
||||
courseDateBlocks,
|
||||
},
|
||||
enableProctoredExams,
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
offer,
|
||||
timeOffsetMillis,
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
marketingUrl,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
|
||||
const [goalToastHeader, setGoalToastHeader] = useState('');
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const eventProperties = {
|
||||
@@ -73,6 +77,14 @@ const OutlineTab = ({ intl }) => {
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const logResumeCourseClick = () => {
|
||||
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
|
||||
...eventProperties,
|
||||
event_type: hasVisitedCourse ? 'resume' : 'start',
|
||||
url: resumeCourseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
// Below the course title alerts (appearing in the order listed here)
|
||||
const courseStartAlert = useCourseStartAlert(courseId);
|
||||
const courseEndAlert = useCourseEndAlert(courseId);
|
||||
@@ -101,33 +113,34 @@ const OutlineTab = ({ intl }) => {
|
||||
return userRoleNames.includes('enterprise_learner');
|
||||
};
|
||||
|
||||
/** [[MM-P2P] Experiment */
|
||||
const MMP2P = initHomeMMP2P(courseId);
|
||||
|
||||
/** show post enrolment survey to only B2C learners */
|
||||
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const currentParams = new URLSearchParams(location.search);
|
||||
const startCourse = currentParams.get('start_course');
|
||||
if (startCourse === '1') {
|
||||
sendTrackEvent('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 (
|
||||
<>
|
||||
<Toast
|
||||
closeLabel={intl.formatMessage(genericMessages.close)}
|
||||
onClose={() => setGoalToastHeader('')}
|
||||
show={!!(goalToastHeader)}
|
||||
>
|
||||
{goalToastHeader}
|
||||
</Toast>
|
||||
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<div role="heading" aria-level="1" className="h2">{title}</div>
|
||||
</div>
|
||||
{resumeCourseUrl && (
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
|
||||
<div className="row course-outline-tab">
|
||||
<AccountActivationAlert />
|
||||
<div className="col-12">
|
||||
@@ -139,34 +152,46 @@ const OutlineTab = ({ intl }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="col col-12 col-md-8">
|
||||
<AlertList
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
{isSelfPaced && hasDeadlines && (
|
||||
{ /** [MM-P2P] Experiment (the conditional) */ }
|
||||
{ !MMP2P.state.isEnabled
|
||||
&& (
|
||||
<AlertList
|
||||
topic="outline-course-alerts"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
...certificateAvailableAlert,
|
||||
...courseEndAlert,
|
||||
...courseStartAlert,
|
||||
...scheduledContentAlert,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
|
||||
<>
|
||||
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
|
||||
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
|
||||
</>
|
||||
)}
|
||||
<StartOrResumeCourseCard />
|
||||
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<CourseGoalCard
|
||||
courseId={courseId}
|
||||
goalOptions={goalOptions}
|
||||
title={title}
|
||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||
/>
|
||||
)}
|
||||
<WelcomeMessage courseId={courseId} />
|
||||
{rootCourseId && (
|
||||
<>
|
||||
<div className="row w-100 m-0 mb-3 justify-content-end">
|
||||
<div className="col-12 col-md-auto p-0">
|
||||
<div className="col-12 col-sm-auto p-0">
|
||||
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
|
||||
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ol id="courseHome-outline" className="list-unstyled">
|
||||
<ol className="list-unstyled">
|
||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
@@ -182,37 +207,53 @@ const OutlineTab = ({ intl }) => {
|
||||
</div>
|
||||
{rootCourseId && (
|
||||
<div className="col col-12 col-md-4">
|
||||
<ProctoringInfoPanel />
|
||||
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as
|
||||
disabled to avoid components bouncing around too much as screen is rendered */ }
|
||||
{(!enableProctoredExams || proctoringPanelStatus === 'loaded') && weeklyLearningGoalEnabled && (
|
||||
<WeeklyLearningGoalCard
|
||||
daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null}
|
||||
subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false}
|
||||
<ProctoringInfoPanel
|
||||
courseId={courseId}
|
||||
username={username}
|
||||
/>
|
||||
{courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
|
||||
<UpdateGoalSelector
|
||||
courseId={courseId}
|
||||
goalOptions={goalOptions}
|
||||
selectedGoal={courseGoalToDisplay}
|
||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||
/>
|
||||
)}
|
||||
<CourseTools />
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
<CourseTools
|
||||
courseId={courseId}
|
||||
/>
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{ MMP2P.state.isEnabled
|
||||
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||
: (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
)}
|
||||
<CourseDates
|
||||
courseId={courseId}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
<CourseHandouts
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
OutlineTab.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
@@ -10,7 +6,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Cookies from 'js-cookie';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import messages from './messages';
|
||||
|
||||
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
|
||||
import {
|
||||
@@ -29,21 +24,21 @@ jest.mock('@edx/frontend-platform/analytics');
|
||||
describe('Outline Tab', () => {
|
||||
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}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}&username=MockUser`;
|
||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata');
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('outlineTabData');
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
@@ -52,14 +47,9 @@ describe('Outline Tab', () => {
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender(path = '') {
|
||||
async function fetchAndRender() {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<OutlineTab />
|
||||
</MemoryRouter>,
|
||||
{ store },
|
||||
));
|
||||
await act(async () => render(<OutlineTab />, { store }));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -83,7 +73,7 @@ describe('Outline Tab', () => {
|
||||
describe('Course Outline', () => {
|
||||
it('displays link to start course', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays link to resume course', async () => {
|
||||
@@ -119,11 +109,11 @@ describe('Outline Tab', () => {
|
||||
|
||||
// Click to expand section
|
||||
userEvent.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click to collapse section
|
||||
userEvent.click(expandButton);
|
||||
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
|
||||
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('displays correct icon for complete assignment', async () => {
|
||||
@@ -144,8 +134,25 @@ describe('Outline Tab', () => {
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays link', async () => {
|
||||
it('SequenceLink displays points to legacy courseware', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: false,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to courseware MFE', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: true,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
@@ -321,164 +328,88 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start or Resume Course Card', () => {
|
||||
it('renders startOrResumeCourseCard', async () => {
|
||||
describe('Course Goals', () => {
|
||||
const goalOptions = [
|
||||
['certify', 'Earn a certificate'],
|
||||
['complete', 'Complete the course'],
|
||||
['explore', 'Explore the course'],
|
||||
['unsure', 'Not sure yet'],
|
||||
];
|
||||
|
||||
it('does not render goal widgets if no goals available', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weekly Learning Goal', () => {
|
||||
it('does not post goals while masquerading', async () => {
|
||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
|
||||
|
||||
await fetchAndRender();
|
||||
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
|
||||
fireEvent.click(button);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('post goal via query param', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
await fetchAndRender('http://localhost/?weekly_goal=3');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('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', () => {
|
||||
describe('goal is not set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
goal_options: goalOptions,
|
||||
selected_goal: null,
|
||||
},
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
});
|
||||
|
||||
it('renders weekly learning goal card', async () => {
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
it('renders goal card', () => {
|
||||
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the subscribe button if no goal is set', async () => {
|
||||
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
|
||||
});
|
||||
it('renders goal selector on goal selection', async () => {
|
||||
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
|
||||
fireEvent.click(certifyGoalButton);
|
||||
|
||||
it.each([
|
||||
{ level: 'Casual', days: 1 },
|
||||
{ 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();
|
||||
const goalSelector = await screen.findByTestId('edit-goal-selector');
|
||||
expect(goalSelector).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('has button for weekly learning goal selected', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
selected_goal: {
|
||||
subscribed_to_reminders: true,
|
||||
days_per_week: 3,
|
||||
describe('goal is set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
goal_options: goalOptions,
|
||||
selected_goal: { text: 'Earn a certificate', key: 'certify' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const button = await screen.queryByTestId('weekly-learning-goal-input-Regular');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('flag-button-selected');
|
||||
});
|
||||
|
||||
it('renders weekly learning goal card if ProctoringInfoPanel is not shown', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
it('renders edit goal selector', () => {
|
||||
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
|
||||
});
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(404);
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders weekly learning goal card if ProctoringInfoPanel is not enabled', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
enableProctoredExams: false,
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
});
|
||||
it('updates goal on click', async () => {
|
||||
// Open dropdown
|
||||
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
|
||||
await waitFor(() => {
|
||||
expect(dropdownButtonNode).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(dropdownButtonNode);
|
||||
|
||||
it('renders weekly learning goal card if ProctoringInfoPanel is enabled', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
enableProctoredExams: true,
|
||||
},
|
||||
// Select a new goal
|
||||
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
|
||||
await waitFor(() => {
|
||||
expect(unsureButtonNode).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(unsureButtonNode);
|
||||
|
||||
// Verify the request was made
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
|
||||
});
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,7 +508,7 @@ describe('Outline Tab', () => {
|
||||
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();
|
||||
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
@@ -668,6 +599,7 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: tomorrow.toISOString(),
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -684,7 +616,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', async () => {
|
||||
const now = new Date();
|
||||
@@ -695,6 +627,7 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -717,7 +650,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
});
|
||||
it('renders non passing grade', async () => {
|
||||
const now = new Date();
|
||||
@@ -750,8 +683,8 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
screen.getAllByText('You are not yet eligible for a certificate');
|
||||
expect(screen.queryByText('You are not yet eligible for a certificate')).toBeInTheDocument();
|
||||
screen.getAllByText('You are not eligible for a certificate');
|
||||
expect(screen.queryByText('You are not eligible for a certificate')).toBeInTheDocument();
|
||||
});
|
||||
it('tracks request cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
@@ -763,6 +696,7 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -789,16 +723,57 @@ describe('Outline Tab', () => {
|
||||
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
||||
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
||||
{
|
||||
courserun_key: courseId,
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
it('tracks download cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
|
||||
{
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks unverified cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
@@ -809,6 +784,7 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -835,14 +811,12 @@ describe('Outline Tab', () => {
|
||||
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',
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
|
||||
{
|
||||
courserun_key: courseId,
|
||||
courserun_key: 'course-v1:edX+Test+run',
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -882,7 +856,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -898,6 +872,7 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: 'certificate/testuuid',
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -923,6 +898,7 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -939,6 +915,33 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate (pdf) Complete Alert', () => {
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: 'download/url',
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proctoring Info Panel', () => {
|
||||
const onboardingReleaseDate = new Date();
|
||||
onboardingReleaseDate.setDate(new Date().getDate() - 7);
|
||||
@@ -1021,22 +1024,6 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('displays expiration warning', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays expiration warning for other course', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
@@ -1048,23 +1035,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays expired', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message appears after expiration, set the date 10 days in the past
|
||||
expirationDate.setTime(expirationDate.getTime() - 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1085,7 +1056,6 @@ describe('Outline Tab', () => {
|
||||
|
||||
it('does not appear for 404', async () => {
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(404);
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1199,7 +1169,7 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Activation Alert', () => {
|
||||
describe('Accont Activation Alert', () => {
|
||||
beforeEach(() => {
|
||||
const intersectionObserverMock = () => ({
|
||||
observe: () => null,
|
||||
@@ -1227,7 +1197,7 @@ describe('Outline Tab', () => {
|
||||
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends account activation email on clicking the re-send email in account activation alert', async () => {
|
||||
it('sends account activation email on clicking the resened email in account activation alert', async () => {
|
||||
Cookies.set = jest.fn();
|
||||
Cookies.get = jest.fn().mockImplementation(() => 'true');
|
||||
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
|
||||
|
||||
@@ -12,13 +12,13 @@ import { useModel } from '../../generic/model-store';
|
||||
import genericMessages from '../../generic/messages';
|
||||
import messages from './messages';
|
||||
|
||||
const Section = ({
|
||||
function Section({
|
||||
courseId,
|
||||
defaultOpen,
|
||||
expand,
|
||||
intl,
|
||||
section,
|
||||
}) => {
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
sequenceIds,
|
||||
@@ -38,7 +38,6 @@ const Section = ({
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sectionTitle = (
|
||||
@@ -110,7 +109,7 @@ const Section = ({
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
@@ -16,73 +17,38 @@ import EffortEstimate from '../../shared/effort-estimate';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
const SequenceLink = ({
|
||||
function SequenceLink({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
first,
|
||||
sequence,
|
||||
}) => {
|
||||
}) {
|
||||
const {
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
legacyWebUrl,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('outline', courseId);
|
||||
const {
|
||||
canLoadCourseware,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
|
||||
const coursewareUrl = (
|
||||
canLoadCourseware
|
||||
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
|
||||
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
|
||||
);
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
const dueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-set"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const noDueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-not-set"
|
||||
defaultMessage="{description}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
@@ -114,15 +80,35 @@ const SequenceLink = ({
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{due ? dueDateMessage : noDueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
{due && (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const CERT_STATUS_TYPE = {
|
||||
UNVERIFIED: 'unverified',
|
||||
};
|
||||
|
||||
const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
function CertificateStatusAlert({ intl, payload }) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
certificateAvailableDate,
|
||||
@@ -33,6 +33,7 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
@@ -65,8 +66,8 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
alertProps.body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.earnedNotAvailable"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
@@ -78,7 +79,11 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
);
|
||||
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
if (isWebCert) {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
}
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = certURL;
|
||||
alertProps.buttonAction = () => {
|
||||
@@ -189,7 +194,7 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
||||
)}
|
||||
</AlertWrapper>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CertificateStatusAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
@@ -199,6 +204,7 @@ CertificateStatusAlert.propTypes = {
|
||||
courseEndDate: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
certURL: PropTypes.string,
|
||||
isWebCert: PropTypes.bool,
|
||||
userTimezone: PropTypes.string,
|
||||
org: PropTypes.string,
|
||||
notPassingCourseEnded: PropTypes.bool,
|
||||
|
||||
@@ -51,8 +51,10 @@ function useCertificateStatusAlert(courseId) {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isWebCert = downloadUrl === null;
|
||||
const isVerifiedEnrollmentMode = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
@@ -61,6 +63,9 @@ function useCertificateStatusAlert(courseId) {
|
||||
let certURL = '';
|
||||
if (certWebViewUrl) {
|
||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
} else if (downloadUrl) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
@@ -75,22 +80,22 @@ function useCertificateStatusAlert(courseId) {
|
||||
&& hasEnded
|
||||
&& !userHasPassingGrade
|
||||
);
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
certificateAvailableDate,
|
||||
certURL,
|
||||
certStatus,
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
}), [certStatus, certURL, certificateAvailableDate, courseId,
|
||||
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);
|
||||
};
|
||||
|
||||
useAlert(isVisible || notPassingCourseEnded, {
|
||||
code: 'clientCertificateStatusAlert',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
certStatusEarnedNotAvailableHeader: {
|
||||
id: 'cert.alert.earned.unavailable.header.v2',
|
||||
defaultMessage: 'Your grade and certificate status will be available soon.',
|
||||
id: 'cert.alert.earned.unavailable.header',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
description: 'Header alerting the user that their certificate will be available soon.',
|
||||
},
|
||||
certStatusDownloadableHeader: {
|
||||
@@ -13,7 +13,7 @@ const messages = defineMessages({
|
||||
},
|
||||
certStatusNotPassingHeader: {
|
||||
id: 'cert.alert.notPassing.header',
|
||||
defaultMessage: 'You are not yet eligible for a certificate',
|
||||
defaultMessage: 'You are not eligible for a certificate',
|
||||
},
|
||||
certStatusNotPassingButton: {
|
||||
id: 'cert.alert.notPassing.button',
|
||||
|
||||
@@ -3,17 +3,15 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelativeTime,
|
||||
FormattedRelative,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
|
||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
||||
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||
|
||||
const CourseEndAlert = ({ payload }) => {
|
||||
function CourseEndAlert({ payload }) {
|
||||
const {
|
||||
description,
|
||||
endDate,
|
||||
@@ -22,19 +20,16 @@ const CourseEndAlert = ({ payload }) => {
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
let msg;
|
||||
const delta = new Date(endDate) - new Date();
|
||||
const timeRemaining = (
|
||||
<FormattedRelativeTime
|
||||
<FormattedRelative
|
||||
key="timeRemaining"
|
||||
value={delta / 1000}
|
||||
numeric="auto"
|
||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
||||
updateIntervalInSeconds={YEAR_SEC}
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
|
||||
let msg;
|
||||
const delta = new Date(endDate) - new Date();
|
||||
if (delta < DAY_MS) {
|
||||
const courseEndTime = (
|
||||
<FormattedTime
|
||||
@@ -88,7 +83,7 @@ const CourseEndAlert = ({ payload }) => {
|
||||
{description}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseEndAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
|
||||
@@ -23,15 +23,15 @@ export function useCourseEndAlert(courseId) {
|
||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||
const delta = endBlock ? endDate - new Date() : 0;
|
||||
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
description: endBlock && endBlock.description,
|
||||
endDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
}), [endBlock, userTimezone]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseEndAlert',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import outlineMessages from '../../messages';
|
||||
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
const PrivateCourseAlert = ({ intl, payload }) => {
|
||||
function PrivateCourseAlert({ intl, payload }) {
|
||||
const {
|
||||
anonymousUser,
|
||||
canEnroll,
|
||||
@@ -100,7 +100,7 @@ const PrivateCourseAlert = ({ intl, payload }) => {
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
PrivateCourseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -18,16 +18,16 @@ export function usePrivateCourseAlert(courseId) {
|
||||
* 2. the user is authenticated.
|
||||
* */
|
||||
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
anonymousUser: authenticatedUser === null,
|
||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||
courseId,
|
||||
}), [authenticatedUser, courseId, outline]);
|
||||
};
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientPrivateCourseAlert',
|
||||
dismissible: false,
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-private-alerts',
|
||||
type: ALERT_TYPES.WELCOME,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Alert, Button } from '@edx/paragon';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ScheduledContentAlert = ({ payload }) => {
|
||||
function ScheduledContentAlert({ payload }) {
|
||||
const {
|
||||
datesTabLink,
|
||||
} = payload;
|
||||
@@ -38,7 +38,7 @@ const ScheduledContentAlert = ({ payload }) => {
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
ScheduledContentAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
|
||||
@@ -20,12 +20,12 @@ const useScheduledContentAlert = (courseId) => {
|
||||
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
|
||||
);
|
||||
const { isEnrolled } = useModel('courseHomeMeta', courseId);
|
||||
const payload = useMemo(() => ({
|
||||
const payload = {
|
||||
datesTabLink,
|
||||
}), [datesTabLink]);
|
||||
};
|
||||
useAlert(hasScheduledContent && isEnrolled, {
|
||||
code: 'ScheduledContentAlert',
|
||||
payload,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
|
||||
@@ -4,22 +4,6 @@ const messages = defineMessages({
|
||||
allDates: {
|
||||
id: 'learning.outline.dates.all',
|
||||
defaultMessage: 'View all course dates',
|
||||
description: 'Text anchor for link that redirects to dates or course timeline tab',
|
||||
},
|
||||
casualGoalButtonText: {
|
||||
id: 'learning.outline.goalButton.casual.text',
|
||||
defaultMessage: '1 day a week',
|
||||
description: 'Text shown for casual goal button',
|
||||
},
|
||||
casualGoalButtonTitle: {
|
||||
id: 'learning.outline.goalButton.screenReader.text',
|
||||
defaultMessage: 'Casual',
|
||||
description: 'A very short description of the least intense of three learning goals',
|
||||
},
|
||||
certAlt: {
|
||||
id: 'learning.outline.certificateAlt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
collapseAll: {
|
||||
id: 'learning.outline.collapseAll',
|
||||
@@ -39,7 +23,6 @@ const messages = defineMessages({
|
||||
dates: {
|
||||
id: 'learning.outline.dates',
|
||||
defaultMessage: 'Important dates',
|
||||
description: 'Headline for the (summary of dates) section of the outline page',
|
||||
},
|
||||
editGoal: {
|
||||
id: 'learning.outline.editGoal',
|
||||
@@ -56,11 +39,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Goal',
|
||||
description: 'Label for the selected course goal',
|
||||
},
|
||||
goalReminderDetail: {
|
||||
id: 'learning.outline.goalReminderDetail',
|
||||
defaultMessage: 'If we notice you’re not quite at your goal, we’ll send you an email reminder.',
|
||||
description: 'It describe to learner what is goal reminder service',
|
||||
},
|
||||
goalUnsure: {
|
||||
id: 'learning.outline.goalUnsure',
|
||||
defaultMessage: 'Not sure yet',
|
||||
@@ -68,7 +46,6 @@ const messages = defineMessages({
|
||||
handouts: {
|
||||
id: 'learning.outline.handouts',
|
||||
defaultMessage: 'Course Handouts',
|
||||
description: 'Header for (Course Handouts) section in course outline',
|
||||
},
|
||||
incompleteAssignment: {
|
||||
id: 'learning.outline.incompleteAssignment',
|
||||
@@ -80,16 +57,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Incomplete section',
|
||||
description: 'Text used to describe the gray checkmark icon in front of a section title',
|
||||
},
|
||||
intenseGoalButtonText: {
|
||||
id: 'learning.outline.goalButton.intense.text',
|
||||
defaultMessage: '5 days a week',
|
||||
description: 'Text shown for intense goal button',
|
||||
},
|
||||
intenseGoalButtonTitle: {
|
||||
id: 'learning.outline.goalButton.intense.title',
|
||||
defaultMessage: 'Intense',
|
||||
description: 'A very short description of the most intensive option of three learning goals, Casual, Regular and Intense',
|
||||
},
|
||||
learnMore: {
|
||||
id: 'learning.outline.learnMore',
|
||||
defaultMessage: 'Learn More',
|
||||
@@ -99,80 +66,34 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Open',
|
||||
description: 'A button to open the given section of the course outline',
|
||||
},
|
||||
proctoringInfoPanel: {
|
||||
id: 'learning.proctoringPanel.header',
|
||||
defaultMessage: 'This course contains proctored exams',
|
||||
description: 'Used as a label to indicate that course has proctored exams',
|
||||
},
|
||||
regularGoalButtonText: {
|
||||
id: 'learning.outline.goalButton.regular.text',
|
||||
defaultMessage: '3 days a week',
|
||||
description: 'Text shown for regular goal button',
|
||||
|
||||
},
|
||||
regularGoalButtonTitle: {
|
||||
id: 'learning.outline.goalButton.regular.title',
|
||||
defaultMessage: 'Regular',
|
||||
description: 'A very short description of the middle option of three learning goals, Casual, Regular and Intense',
|
||||
},
|
||||
resumeBlurb: {
|
||||
id: 'learning.outline.resumeBlurb',
|
||||
defaultMessage: 'Pick up where you left off',
|
||||
description: 'Text describing to the learner that they can return to the last section they visited within the course.',
|
||||
},
|
||||
resume: {
|
||||
id: 'learning.outline.resume',
|
||||
defaultMessage: 'Resume course',
|
||||
description: 'Anchor text for button that would resume course',
|
||||
},
|
||||
setGoal: {
|
||||
id: 'learning.outline.setGoal',
|
||||
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.',
|
||||
description: 'In indicate to learner how to set or use the goal reminder service',
|
||||
},
|
||||
setGoalReminder: {
|
||||
id: 'learning.outline.setGoalReminder',
|
||||
defaultMessage: 'Set a goal reminder',
|
||||
description: 'The text for the radio button which activate or deactivate the goal reminder service',
|
||||
},
|
||||
setLearningGoalButtonScreenReaderText: {
|
||||
id: 'learning.outline.goalButton.casual.title',
|
||||
defaultMessage: 'Set a learning goal style.',
|
||||
description: 'screen reader text informing learner they can select an intensity of learning goal',
|
||||
},
|
||||
setWeeklyGoal: {
|
||||
id: 'learning.outline.setWeeklyGoal',
|
||||
defaultMessage: 'Set a weekly learning goal',
|
||||
description: 'The headline for (goal reminder service) section in course outline',
|
||||
},
|
||||
setWeeklyGoalDetail: {
|
||||
id: 'learning.outline.setWeeklyGoalDetail',
|
||||
defaultMessage: 'Setting a goal motivates you to finish the course. You can always change it later.',
|
||||
description: 'It indiacate the gaol or the purpose of the goal reminder service to learners',
|
||||
},
|
||||
start: {
|
||||
id: 'learning.outline.start',
|
||||
defaultMessage: 'Start course',
|
||||
description: 'The text for button which starts the course',
|
||||
},
|
||||
startBlurb: {
|
||||
id: 'learning.outline.startBlurb',
|
||||
defaultMessage: 'Begin your course today',
|
||||
defaultMessage: 'Start Course',
|
||||
},
|
||||
tools: {
|
||||
id: 'learning.outline.tools',
|
||||
defaultMessage: 'Course Tools',
|
||||
description: 'Headline for the (course tools) section in course outline. course tool might include links to course bookmarks, financial assistance...etc',
|
||||
},
|
||||
upgradeButton: {
|
||||
id: 'learning.outline.upgradeButton',
|
||||
defaultMessage: 'Upgrade ({symbol}{price})',
|
||||
description: 'Text for the button which redirects to the upgrading page',
|
||||
},
|
||||
upgradeTitle: {
|
||||
id: 'learning.outline.upgradeTitle',
|
||||
defaultMessage: 'Pursue a verified certificate',
|
||||
description: 'Upgrade title',
|
||||
},
|
||||
certAlt: {
|
||||
id: 'learning.outline.certificateAlt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
welcomeMessage: {
|
||||
id: 'learning.outline.welcomeMessage',
|
||||
@@ -191,145 +112,113 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Welcome to',
|
||||
description: 'This precedes the title of the course',
|
||||
},
|
||||
proctoringInfoPanel: {
|
||||
id: 'learning.proctoringPanel.header',
|
||||
defaultMessage: 'This course contains proctored exams',
|
||||
},
|
||||
notStartedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.notStarted',
|
||||
defaultMessage: 'Not Started',
|
||||
description: 'It indcate that proctortrack onboarding exam hasn’t started yet',
|
||||
},
|
||||
startedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.started',
|
||||
defaultMessage: 'Started',
|
||||
description: 'Label to indicate the starting status of the proctortrack onboarding exam',
|
||||
},
|
||||
submittedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.submitted',
|
||||
defaultMessage: 'Submitted',
|
||||
description: 'Label to indicate the submitted status of proctortrack onboarding exam',
|
||||
},
|
||||
verifiedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.verified',
|
||||
defaultMessage: 'Verified',
|
||||
description: 'Label to indicate the verified status of the proctortrack onboarding exam',
|
||||
},
|
||||
rejectedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
description: 'Label to indicate the rejection status of the proctortrack onboarding exam',
|
||||
},
|
||||
errorProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.error',
|
||||
defaultMessage: 'Error',
|
||||
description: 'Label to indicate that there is error in proctortrack onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.otherCourseApproved',
|
||||
defaultMessage: 'Approved in Another Course',
|
||||
description: 'Label to indicate that the proctortrack onboarding exam is verified based on taking onboarding exam on another course',
|
||||
},
|
||||
expiringSoonProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expiringSoon',
|
||||
defaultMessage: 'Expiring Soon',
|
||||
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
|
||||
},
|
||||
expiredProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expired',
|
||||
defaultMessage: 'Expired',
|
||||
description: 'A label to indicate that proctortrack onboarding exam has expired',
|
||||
},
|
||||
proctoringCurrentStatus: {
|
||||
id: 'learning.proctoringPanel.status',
|
||||
defaultMessage: 'Current Onboarding Status:',
|
||||
description: 'The text that precede the status label of proctortrack onboarding exam',
|
||||
},
|
||||
notStartedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.notStarted',
|
||||
defaultMessage: 'You have not started your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (not started) label of the proctortrack onboarding exam',
|
||||
},
|
||||
startedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.started',
|
||||
defaultMessage: 'You have started your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam',
|
||||
},
|
||||
submittedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.submitted',
|
||||
defaultMessage: 'You have submitted your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam',
|
||||
},
|
||||
verifiedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.verified',
|
||||
defaultMessage: 'Your onboarding exam has been approved in this course.',
|
||||
description: 'The text that explain the meaning of (verified) label of the proctortrack onboarding exam',
|
||||
},
|
||||
rejectedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.rejected',
|
||||
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
|
||||
description: 'The text that explain the meaning of (rejected) label of the proctortrack onboarding exam',
|
||||
},
|
||||
errorProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.error',
|
||||
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
|
||||
description: 'The text that explain the meaning of (error) label of the proctortrack onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.otherCourseApproved',
|
||||
defaultMessage: 'Your onboarding exam has been approved in another course.',
|
||||
description: 'The text that explain the meaning of (approved in another course) label of the proctortrack onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringDetail: {
|
||||
id: 'learning.proctoringPanel.detail.otherCourseApproved',
|
||||
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (approved in another course)',
|
||||
},
|
||||
expiringSoonProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
|
||||
},
|
||||
expiredProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expired',
|
||||
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
},
|
||||
proctoringPanelGeneralInfo: {
|
||||
id: 'learning.proctoringPanel.generalInfo',
|
||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
||||
description: 'It indicate key and important fact to learner about the importance of taking proctortrack onboarding exam',
|
||||
},
|
||||
proctoringPanelGeneralInfoSubmitted: {
|
||||
id: 'learning.proctoringPanel.generalInfoSubmitted',
|
||||
defaultMessage: 'Your submitted profile is in review.',
|
||||
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam',
|
||||
},
|
||||
proctoringPanelGeneralTime: {
|
||||
id: 'learning.proctoringPanel.generalTime',
|
||||
defaultMessage: 'Onboarding profile review can take 2+ business days.',
|
||||
description: 'This text explain for how long the (in review) status of the proctortrack onboarding exam might remain',
|
||||
},
|
||||
proctoringOnboardingButton: {
|
||||
id: 'learning.proctoringPanel.onboardingButton',
|
||||
defaultMessage: 'Complete Onboarding',
|
||||
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released',
|
||||
},
|
||||
proctoringOnboardingPracticeButton: {
|
||||
id: 'learning.proctoringPanel.onboardingPracticeButton',
|
||||
defaultMessage: 'View Onboarding Exam',
|
||||
description: 'The text that appears on onboarding exam while its not released, so learners can take or view it as a practice',
|
||||
},
|
||||
proctoringOnboardingButtonNotOpen: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonNotOpen',
|
||||
defaultMessage: 'Onboarding Opens: {releaseDate}',
|
||||
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam',
|
||||
},
|
||||
proctoringReviewRequirementsButton: {
|
||||
id: 'learning.proctoringPanel.reviewRequirementsButton',
|
||||
defaultMessage: 'Review instructions and system requirements',
|
||||
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctortrack onboarding exam',
|
||||
},
|
||||
proctoringOnboardingButtonPastDue: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonPastDue',
|
||||
defaultMessage: 'Onboarding Past Due',
|
||||
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
@@ -7,12 +7,12 @@ import DateSummary from '../DateSummary';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const CourseDates = ({
|
||||
function CourseDates({
|
||||
courseId,
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
@@ -29,27 +29,36 @@ const CourseDates = ({
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<div id="courseHome-dates">
|
||||
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
|
||||
<ol className="list-unstyled">
|
||||
{courseDateBlocks.map((courseDateBlock) => (
|
||||
<DateSummary
|
||||
key={courseDateBlock.title + courseDateBlock.date}
|
||||
dateBlock={courseDateBlock}
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
</div>
|
||||
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
|
||||
<ol className="list-unstyled">
|
||||
{courseDateBlocks.map((courseDateBlock) => (
|
||||
<DateSummary
|
||||
key={courseDateBlock.title + courseDateBlock.date}
|
||||
dateBlock={courseDateBlock}
|
||||
userTimezone={userTimezone}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
||||
{intl.formatMessage(messages.allDates)}
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseDates.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
CourseDates.defaultProps = {
|
||||
courseId: null,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {},
|
||||
};
|
||||
|
||||
export default injectIntl(CourseDates);
|
||||
|
||||
94
src/course-home/outline-tab/widgets/CourseGoalCard.jsx
Normal file
94
src/course-home/outline-tab/widgets/CourseGoalCard.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Card } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
import { saveCourseGoal } from '../../data';
|
||||
|
||||
function CourseGoalCard({
|
||||
courseId,
|
||||
goalOptions,
|
||||
intl,
|
||||
title,
|
||||
setGoalToDisplay,
|
||||
setGoalToastHeader,
|
||||
}) {
|
||||
function selectGoalHandler(event) {
|
||||
const selectedGoal = {
|
||||
key: event.currentTarget.getAttribute('data-goal-key'),
|
||||
text: event.currentTarget.getAttribute('data-goal-text'),
|
||||
};
|
||||
|
||||
saveCourseGoal(courseId, selectedGoal.key).then((response) => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
} = data;
|
||||
|
||||
setGoalToDisplay(selectedGoal);
|
||||
setGoalToastHeader(header);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-3" data-testid="course-goal-card">
|
||||
<Card.Body>
|
||||
<div className="row w-100 m-0 justify-content-between align-items-center">
|
||||
<div className="col col-8 p-0">
|
||||
<h2 className="h4 m-0">{intl.formatMessage(messages.welcomeTo)} {title}</h2>
|
||||
</div>
|
||||
<div className="col col-auto p-0">
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0"
|
||||
size="sm"
|
||||
block
|
||||
data-goal-key="unsure"
|
||||
data-goal-text={`${intl.formatMessage(messages.goalUnsure)}`}
|
||||
onClick={(event) => { selectGoalHandler(event); }}
|
||||
>
|
||||
{intl.formatMessage(messages.goalUnsure)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card.Text className="my-2 mx-1 text-dark-500">{intl.formatMessage(messages.setGoal)}</Card.Text>
|
||||
<div className="row w-100 m-0">
|
||||
{goalOptions.map((goal) => {
|
||||
const [goalKey, goalText] = goal;
|
||||
return (
|
||||
(goalKey !== 'unsure') && (
|
||||
<div key={`goal-${goalKey}`} className="col-auto flex-grow-1 mx-1 my-2 p-0">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
block
|
||||
data-goal-key={goalKey}
|
||||
data-goal-text={goalText}
|
||||
onClick={(event) => { selectGoalHandler(event); }}
|
||||
>
|
||||
{goalText}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
CourseGoalCard.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
goalOptions: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
setGoalToDisplay: PropTypes.func.isRequired,
|
||||
setGoalToastHeader: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseGoalCard);
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
@@ -7,10 +7,7 @@ import LmsHtmlFragment from '../LmsHtmlFragment';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const CourseHandouts = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
function CourseHandouts({ courseId, intl }) {
|
||||
const {
|
||||
handoutsHtml,
|
||||
} = useModel('outline', courseId);
|
||||
@@ -29,9 +26,10 @@ const CourseHandouts = ({ intl }) => {
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseHandouts.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
@@ -12,12 +12,8 @@ import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
|
||||
|
||||
const CourseTools = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
function CourseTools({ courseId, intl }) {
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
courseTools,
|
||||
@@ -73,16 +69,18 @@ const CourseTools = ({ intl }) => {
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li className="small" id="courseHome-launchTourLink">
|
||||
<LaunchCourseHomeTourButton />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
CourseTools.propTypes = {
|
||||
courseId: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseTools.defaultProps = {
|
||||
courseId: null,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTools);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const FlagButton = ({
|
||||
buttonIcon,
|
||||
title,
|
||||
text,
|
||||
handleSelect,
|
||||
isSelected,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={classnames(
|
||||
'flag-button row w-100 align-content-between m-1.5 py-3.5',
|
||||
isSelected ? 'flag-button-selected' : '',
|
||||
)}
|
||||
aria-checked={isSelected}
|
||||
role="radio"
|
||||
onClick={() => handleSelect()}
|
||||
data-testid={`weekly-learning-goal-input-${title}`}
|
||||
>
|
||||
<div className="row w-100 m-0 justify-content-center pb-1">
|
||||
{buttonIcon}
|
||||
</div>
|
||||
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
|
||||
{text}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
FlagButton.propTypes = {
|
||||
buttonIcon: PropTypes.element.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
handleSelect: PropTypes.func.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default FlagButton;
|
||||
@@ -1,37 +0,0 @@
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
|
||||
.flag-button {
|
||||
background-color: $white;
|
||||
border: 1px solid $light-400;
|
||||
border-radius: .2rem;
|
||||
box-shadow: 0 0 0 2px $light-400;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid $primary-300;
|
||||
box-shadow: 0 0 0 2px $white;
|
||||
}
|
||||
}
|
||||
|
||||
.flag-button-selected {
|
||||
border: 1px solid $primary-300;
|
||||
box-shadow: 0 0 0 2px $primary-300;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// @see https://heydonworks.com/article/the-flexbox-holy-albatross-reincarnated/
|
||||
// use the container size for layout instead of device media query
|
||||
.flag-button-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
--margin: 1rem;
|
||||
--modifier: calc(20rem - 100%);
|
||||
margin: calc(var(--margin) * -1);
|
||||
}
|
||||
|
||||
.flag-button-container > * {
|
||||
flex-grow: 1;
|
||||
flex-basis: calc(var(--modifier) * 999);
|
||||
margin: var(--margin);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
// These flag svgs are derivatives of the Flag icon from paragon
|
||||
import { ReactComponent as FlagIntenseIcon } from './flag_black.svg';
|
||||
import { ReactComponent as FlagCasualIcon } from './flag_outline.svg';
|
||||
import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
|
||||
import FlagButton from './FlagButton';
|
||||
import messages from '../messages';
|
||||
|
||||
const LearningGoalButton = ({
|
||||
level,
|
||||
isSelected,
|
||||
handleSelect,
|
||||
intl,
|
||||
}) => {
|
||||
const buttonDetails = {
|
||||
casual: {
|
||||
daysPerWeek: 1,
|
||||
title: messages.casualGoalButtonTitle,
|
||||
text: messages.casualGoalButtonText,
|
||||
icon: <FlagCasualIcon />,
|
||||
},
|
||||
regular: {
|
||||
daysPerWeek: 3,
|
||||
title: messages.regularGoalButtonTitle,
|
||||
text: messages.regularGoalButtonText,
|
||||
icon: <FlagRegularIcon />,
|
||||
},
|
||||
intense: {
|
||||
daysPerWeek: 5,
|
||||
title: messages.intenseGoalButtonTitle,
|
||||
text: messages.intenseGoalButtonText,
|
||||
icon: <FlagIntenseIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
const values = buttonDetails[level];
|
||||
|
||||
return (
|
||||
<FlagButton
|
||||
buttonIcon={values.icon}
|
||||
title={intl.formatMessage(values.title)}
|
||||
text={intl.formatMessage(values.text)}
|
||||
handleSelect={() => handleSelect(values.daysPerWeek)}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LearningGoalButton.propTypes = {
|
||||
level: PropTypes.string.isRequired,
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
handleSelect: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearningGoalButton);
|
||||
@@ -1,24 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import camelCase from 'lodash.camelcase';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { getProctoringInfoData } from '../../data/api';
|
||||
import { fetchProctoringInfoResolved } from '../../data/slice';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const ProctoringInfoPanel = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
username,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
function ProctoringInfoPanel({ courseId, username, intl }) {
|
||||
const [link, setLink] = useState('');
|
||||
const [onboardingPastDue, setOnboardingPastDue] = useState(false);
|
||||
const [showInfoPanel, setShowInfoPanel] = useState(false);
|
||||
@@ -35,7 +25,6 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
error: 'error',
|
||||
otherCourseApproved: 'otherCourseApproved',
|
||||
expiringSoon: 'expiringSoon',
|
||||
expired: 'expired',
|
||||
};
|
||||
|
||||
function getReadableStatusClass(examStatus) {
|
||||
@@ -55,14 +44,9 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
return readableClass;
|
||||
}
|
||||
|
||||
function isCurrentlySubmitted(examStatus) {
|
||||
const SUBMITTED_STATES = ['submitted', 'second_review_required'];
|
||||
return SUBMITTED_STATES.includes(examStatus);
|
||||
}
|
||||
|
||||
function isSubmissionRequired(examStatus) {
|
||||
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
|
||||
return !OK_STATES.includes(examStatus);
|
||||
function isNotYetSubmitted(examStatus) {
|
||||
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
|
||||
return !NO_SHOW_STATES.includes(examStatus);
|
||||
}
|
||||
|
||||
function isNotYetReleased(examReleaseDate) {
|
||||
@@ -83,19 +67,11 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
return borderClass;
|
||||
}
|
||||
|
||||
function isExpired(dateString) {
|
||||
// Returns true if the expiration date has passed
|
||||
const today = new Date();
|
||||
const expirationDateObject = new Date(dateString);
|
||||
return today >= expirationDateObject.getTime();
|
||||
}
|
||||
|
||||
function isExpiringSoon(dateString) {
|
||||
// Returns true if the expiration date is within 28 days
|
||||
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
|
||||
const today = new Date();
|
||||
const expirationDateObject = new Date(dateString);
|
||||
return today > expirationDateObject.getTime() - twentyeightDays;
|
||||
return today > expirationDateObject.getTime() - 2419200000;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,9 +86,7 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
setStatus(response.onboarding_status);
|
||||
setLink(response.onboarding_link);
|
||||
const expirationDate = response.expiration_date;
|
||||
if (expirationDate && isExpired(expirationDate)) {
|
||||
setReadableStatus(getReadableStatusClass('expired'));
|
||||
} else if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||
if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||
setReadableStatus(getReadableStatusClass('expiringSoon'));
|
||||
} else {
|
||||
setReadableStatus(getReadableStatusClass(response.onboarding_status));
|
||||
@@ -121,14 +95,7 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
setOnboardingPastDue(response.onboarding_past_due);
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(() => {
|
||||
/* Do nothing. API throws 404 when class does not have proctoring */
|
||||
})
|
||||
.finally(() => {
|
||||
dispatch(fetchProctoringInfoResolved());
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
);
|
||||
}, []);
|
||||
|
||||
let onboardingExamButton = null;
|
||||
@@ -171,7 +138,6 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{ showInfoPanel && (
|
||||
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
|
||||
@@ -193,17 +159,17 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
|
||||
<>
|
||||
<p>
|
||||
{!isCurrentlySubmitted(status) && (
|
||||
{isNotYetSubmitted(status) && (
|
||||
intl.formatMessage(messages.proctoringPanelGeneralInfo)
|
||||
)}
|
||||
{isCurrentlySubmitted(status) && (
|
||||
{!isNotYetSubmitted(status) && (
|
||||
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
|
||||
)}
|
||||
</p>
|
||||
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
|
||||
</>
|
||||
)}
|
||||
{isSubmissionRequired(readableStatus) && (
|
||||
{isNotYetSubmitted(status) && (
|
||||
onboardingExamButton
|
||||
)}
|
||||
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
|
||||
@@ -214,10 +180,16 @@ const ProctoringInfoPanel = ({ intl }) => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
ProctoringInfoPanel.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
username: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ProctoringInfoPanel.defaultProps = {
|
||||
username: null,
|
||||
};
|
||||
|
||||
export default injectIntl(ProctoringInfoPanel);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Card } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const StartOrResumeCourseCard = ({ intl }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const eventProperties = {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
};
|
||||
|
||||
const {
|
||||
resumeCourse: {
|
||||
hasVisitedCourse,
|
||||
url: resumeCourseUrl,
|
||||
},
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (!resumeCourseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logResumeCourseClick = () => {
|
||||
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
|
||||
...eventProperties,
|
||||
event_type: hasVisitedCourse ? 'resume' : 'start',
|
||||
url: resumeCourseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-3 raised-card" data-testid="start-resume-card">
|
||||
<Card.Header
|
||||
title={hasVisitedCourse ? intl.formatMessage(messages.resumeBlurb) : intl.formatMessage(messages.startBlurb)}
|
||||
actions={(
|
||||
<Button
|
||||
variant="brand"
|
||||
block
|
||||
href={resumeCourseUrl}
|
||||
onClick={() => logResumeCourseClick()}
|
||||
>
|
||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
|
||||
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
|
||||
<Card.Footer><></></Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
StartOrResumeCourseCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(StartOrResumeCourseCard);
|
||||
85
src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
Normal file
85
src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
import { saveCourseGoal } from '../../data';
|
||||
|
||||
function UpdateGoalSelector({
|
||||
courseId,
|
||||
goalOptions,
|
||||
intl,
|
||||
selectedGoal,
|
||||
setGoalToDisplay,
|
||||
setGoalToastHeader,
|
||||
}) {
|
||||
function selectGoalHandler(event) {
|
||||
const key = event.currentTarget.id;
|
||||
const text = event.currentTarget.innerText;
|
||||
const newGoal = {
|
||||
key,
|
||||
text,
|
||||
};
|
||||
|
||||
setGoalToDisplay(newGoal);
|
||||
saveCourseGoal(courseId, key).then((response) => {
|
||||
const { data } = response;
|
||||
const {
|
||||
header,
|
||||
} = data;
|
||||
|
||||
setGoalToastHeader(header);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mb-4">
|
||||
<div className="row w-100 m-0">
|
||||
<div className="col-12 p-0">
|
||||
<label className="h4 m-0" htmlFor="edit-goal-selector">
|
||||
{intl.formatMessage(messages.goal)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12 p-0">
|
||||
<Dropdown className="py-2">
|
||||
<Dropdown.Toggle variant="outline-primary" block id="edit-goal-selector" data-testid="edit-goal-selector">
|
||||
{selectedGoal.text}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{goalOptions.map(([goalKey, goalText]) => (
|
||||
<Dropdown.Item
|
||||
id={goalKey}
|
||||
key={goalKey}
|
||||
onClick={(event) => { selectGoalHandler(event); }}
|
||||
role="button"
|
||||
>
|
||||
{goalText}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateGoalSelector.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
goalOptions: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
selectedGoal: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
}).isRequired,
|
||||
setGoalToDisplay: PropTypes.func.isRequired,
|
||||
setGoalToastHeader: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(UpdateGoalSelector);
|
||||
@@ -1,162 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form, Card, Icon } from '@edx/paragon';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Email } from '@edx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import messages from '../messages';
|
||||
import LearningGoalButton from './LearningGoalButton';
|
||||
import { saveWeeklyLearningGoal } from '../../data';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import './FlagButton.scss';
|
||||
|
||||
const WeeklyLearningGoalCard = ({
|
||||
daysPerWeek,
|
||||
subscribedToReminders,
|
||||
intl,
|
||||
}) => {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
|
||||
const {
|
||||
isMasquerading,
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
|
||||
const location = useLocation();
|
||||
|
||||
const handleSelect = (days, triggeredFromEmail = false) => {
|
||||
// Set the subscription button if this is the first time selecting a goal
|
||||
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
|
||||
setGetReminderSelected(selectReminders);
|
||||
setDaysPerWeekGoal(days);
|
||||
if (!isMasquerading) { // don't save goal updates while masquerading
|
||||
saveWeeklyLearningGoal(courseId, days, selectReminders);
|
||||
sendTrackEvent('edx.ui.lms.goal.days-per-week.changed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
num_days: days,
|
||||
reminder_selected: selectReminders,
|
||||
});
|
||||
if (triggeredFromEmail) {
|
||||
sendTrackEvent('enrollment.email.clicked.setgoal', {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleSubscribeToReminders(event) {
|
||||
const isGetReminderChecked = event.target.checked;
|
||||
setGetReminderSelected(isGetReminderChecked);
|
||||
if (!isMasquerading) { // don't save goal updates while masquerading
|
||||
saveWeeklyLearningGoal(courseId, daysPerWeekGoal, isGetReminderChecked);
|
||||
sendTrackEvent('edx.ui.lms.goal.reminder-selected.changed', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
is_staff: administrator,
|
||||
num_days: daysPerWeekGoal,
|
||||
reminder_selected: isGetReminderChecked,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentParams = new URLSearchParams(location.search);
|
||||
const weeklyGoal = Number(currentParams.get('weekly_goal'));
|
||||
if ([1, 3, 5].includes(weeklyGoal)) {
|
||||
handleSelect(weeklyGoal, true);
|
||||
|
||||
// Deleting the weekly_goal query param as it only needs to be set once
|
||||
// whenever passed in query params.
|
||||
currentParams.delete('weekly_goal');
|
||||
history.replace({
|
||||
search: currentParams.toString(),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
id="courseHome-weeklyLearningGoal"
|
||||
className="row w-100 m-0 mb-3 raised-card"
|
||||
data-testid="weekly-learning-goal-card"
|
||||
>
|
||||
<Card.Header
|
||||
size="sm"
|
||||
title={(<div id="set-weekly-goal-header">{intl.formatMessage(messages.setWeeklyGoal)}</div>)}
|
||||
subtitle={intl.formatMessage(messages.setWeeklyGoalDetail)}
|
||||
/>
|
||||
<Card.Section className="text-gray-700 small">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby="set-weekly-goal-header"
|
||||
className="flag-button-container m-0 p-0"
|
||||
>
|
||||
<LearningGoalButton
|
||||
level="casual"
|
||||
isSelected={daysPerWeekGoal === 1}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
<LearningGoalButton
|
||||
level="regular"
|
||||
isSelected={daysPerWeekGoal === 3}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
<LearningGoalButton
|
||||
level="intense"
|
||||
isSelected={daysPerWeekGoal === 5}
|
||||
handleSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex pt-3">
|
||||
<Form.Switch
|
||||
checked={isGetReminderSelected}
|
||||
onChange={(event) => handleSubscribeToReminders(event)}
|
||||
disabled={!daysPerWeekGoal}
|
||||
>
|
||||
<small>{intl.formatMessage(messages.setGoalReminder)}</small>
|
||||
</Form.Switch>
|
||||
</div>
|
||||
</Card.Section>
|
||||
{isGetReminderSelected && (
|
||||
<Card.Section muted>
|
||||
<div className="row w-100 m-0 small align-center">
|
||||
<div className="d-flex align-items-center pr-1">
|
||||
<Icon
|
||||
className="text-primary-500"
|
||||
src={Email}
|
||||
/>
|
||||
</div>
|
||||
<div className="col">
|
||||
{intl.formatMessage(messages.goalReminderDetail)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Section>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
WeeklyLearningGoalCard.propTypes = {
|
||||
daysPerWeek: PropTypes.number,
|
||||
subscribedToReminders: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
WeeklyLearningGoalCard.defaultProps = {
|
||||
daysPerWeek: null,
|
||||
subscribedToReminders: false,
|
||||
};
|
||||
export default injectIntl(WeeklyLearningGoalCard);
|
||||
@@ -11,22 +11,21 @@ import messages from '../messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { dismissWelcomeMessage } from '../../data/thunks';
|
||||
|
||||
const WelcomeMessage = ({ courseId, intl }) => {
|
||||
function WelcomeMessage({ courseId, intl }) {
|
||||
const {
|
||||
welcomeMessageHtml,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
if (!welcomeMessageHtml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [display, setDisplay] = useState(true);
|
||||
|
||||
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
|
||||
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
|
||||
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
if (!welcomeMessageHtml) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
data-testid="alert-container-welcome"
|
||||
@@ -38,7 +37,6 @@ const WelcomeMessage = ({ courseId, intl }) => {
|
||||
setDisplay(false);
|
||||
dispatch(dismissWelcomeMessage(courseId));
|
||||
}}
|
||||
className="raised-card"
|
||||
actions={messageCanBeShortened ? [
|
||||
<Button
|
||||
onClick={() => setShowShortMessage(!showShortMessage)}
|
||||
@@ -70,7 +68,7 @@ const WelcomeMessage = ({ courseId, intl }) => {
|
||||
</TransitionReplace>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
WelcomeMessage.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4 6L14 4H5V21H7V14H12.6L13 16H20V6H14.4Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 173 B |
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="15"
|
||||
height="17"
|
||||
viewBox="0 0 15 17"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg11"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.4 2L9 0H0V17H2V10H7.6L8 12H15V2H9.4ZM13 10H9.64L9.24 8H2V2H7.36L7.76 4H13V10Z"
|
||||
fill="#002B2B"
|
||||
id="path9" />
|
||||
<path
|
||||
style="fill:#808080;fill-rule:evenodd;stroke-width:0.0150977"
|
||||
d="M 9.6594698,9.9871226 C 9.6577909,9.9829707 9.5654776,9.5311723 9.4543296,8.9831261 L 9.2522415,7.9866785 5.6376662,7.9790074 2.0230906,7.9713362 V 4.9970494 2.0227625 l 2.6636151,0.00771 2.6636151,0.00771 0.1968204,0.9888987 0.1968205,0.9888988 h 2.6200263 2.620026 v 2.9893428 2.9893428 h -1.660746 c -0.91341,0 -1.6621194,-0.0034 -1.6637982,-0.00755 z"
|
||||
id="path302" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 801 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="15" height="17" viewBox="0 0 15 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.4 2L9 0H0V17H2V10H7.6L8 12H15V2H9.4ZM13 10H9.64L9.24 8H2V2H7.36L7.76 4H13V10Z" fill="#002B2B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 211 B |
@@ -9,7 +9,7 @@ import { useModel } from '../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const ProgressHeader = ({ intl }) => {
|
||||
function ProgressHeader({ intl }) {
|
||||
const {
|
||||
courseId,
|
||||
targetUserId,
|
||||
@@ -26,16 +26,18 @@ const ProgressHeader = ({ intl }) => {
|
||||
: intl.formatMessage(messages.progressHeader);
|
||||
|
||||
return (
|
||||
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
||||
<h1>{pageTitle}</h1>
|
||||
{administrator && studioUrl && (
|
||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
||||
<h1>{pageTitle}</h1>
|
||||
{administrator && studioUrl && (
|
||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||
{intl.formatMessage(messages.studioLink)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
ProgressHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { layoutGenerator } from 'react-break';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import CertificateStatus from './certificate-status/CertificateStatus';
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -12,7 +12,7 @@ import RelatedLinks from './related-links/RelatedLinks';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const ProgressTab = () => {
|
||||
function ProgressTab() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseHome);
|
||||
@@ -23,15 +23,13 @@ const ProgressTab = () => {
|
||||
|
||||
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
const windowWidth = useWindowSize().width;
|
||||
if (windowWidth === undefined) {
|
||||
// Bail because we don't want to load <CertificateStatus/> twice, emitting 'visited' events both times.
|
||||
// This is a hacky solution, since the user can resize the screen and still get two visited events.
|
||||
// But I'm leaving a larger refactor as an exercise to a future reader.
|
||||
return null;
|
||||
}
|
||||
const layout = layoutGenerator({
|
||||
mobile: 0,
|
||||
desktop: 992,
|
||||
});
|
||||
|
||||
const wideScreen = windowWidth >= breakpoints.large.minWidth;
|
||||
const OnMobile = layout.is('mobile');
|
||||
const OnDesktop = layout.isAtLeast('desktop');
|
||||
return (
|
||||
<>
|
||||
<ProgressHeader />
|
||||
@@ -39,9 +37,11 @@ const ProgressTab = () => {
|
||||
{/* Main body */}
|
||||
<div className="col-12 col-md-8 p-0">
|
||||
<CourseCompletion />
|
||||
{!wideScreen && <CertificateStatus />}
|
||||
<OnMobile>
|
||||
<CertificateStatus />
|
||||
</OnMobile>
|
||||
<CourseGrade />
|
||||
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
|
||||
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
|
||||
<GradeSummary />
|
||||
<DetailedGrades />
|
||||
</div>
|
||||
@@ -49,12 +49,14 @@ const ProgressTab = () => {
|
||||
|
||||
{/* Side panel */}
|
||||
<div className="col-12 col-md-4 p-0 px-md-4">
|
||||
{wideScreen && <CertificateStatus />}
|
||||
<OnDesktop>
|
||||
<CertificateStatus />
|
||||
</OnDesktop>
|
||||
<RelatedLinks />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ProgressTab;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
@@ -14,7 +13,6 @@ import * as thunks from '../data/thunks';
|
||||
import initializeStore from '../../store';
|
||||
import ProgressTab from './ProgressTab';
|
||||
import LoadedTabPage from '../../tab-page/LoadedTabPage';
|
||||
import messages from './grades/messages';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
@@ -22,21 +20,18 @@ jest.mock('@edx/frontend-platform/analytics');
|
||||
describe('Progress Tab', () => {
|
||||
let axiosMock;
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata');
|
||||
const defaultTabData = Factory.build('progressTabData');
|
||||
|
||||
const courseId = defaultMetadata.id;
|
||||
const courseId = 'course-v1:edX+Test+run';
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
|
||||
|
||||
const store = initializeStore();
|
||||
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
|
||||
const defaultTabData = Factory.build('progressTabData');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -68,7 +63,6 @@ describe('Progress Tab', () => {
|
||||
|
||||
it('sends event on click of dates tab link', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const datesTabLink = screen.getByRole('link', { name: 'Dates' });
|
||||
fireEvent.click(datesTabLink);
|
||||
@@ -84,7 +78,6 @@ describe('Progress Tab', () => {
|
||||
|
||||
it('sends event on click of outline tab link', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
|
||||
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
|
||||
@@ -136,8 +129,6 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -171,8 +162,6 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('0%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('0%');
|
||||
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -220,8 +209,6 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('80%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('80%');
|
||||
expect(await screen.findByText('You’re currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -298,6 +285,7 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
setTabData({
|
||||
completion_summary: {
|
||||
complete_count: 1,
|
||||
@@ -334,7 +322,6 @@ describe('Progress Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
expect(screen.getByText('locked feature')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
|
||||
|
||||
@@ -451,8 +438,9 @@ describe('Progress Tab', () => {
|
||||
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('does not render subsections for which showGrades is false', async () => {
|
||||
// The second assignment has showGrades set to false, so it should not be shown.
|
||||
it('renders correct current grade tooltip when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
setTabData({
|
||||
section_scores: [
|
||||
{
|
||||
@@ -494,31 +482,10 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('First subsection')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct title when credit information is available', async () => {
|
||||
setTabData({
|
||||
credit_course_requirements: {
|
||||
eligibility_status: 'eligible',
|
||||
requirements: [
|
||||
{
|
||||
namespace: 'proctored_exam',
|
||||
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
|
||||
display_name: 'Proctored Mid Term Exam',
|
||||
criteria: {},
|
||||
reason: {},
|
||||
status: 'satisfied',
|
||||
status_date: '2015-06-26 11:07:42',
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
// Although the learner's true grade is passing, we should expect this to reflect the grade that's
|
||||
// visible to them, which is non-passing
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -667,7 +634,9 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders override notice', async () => {
|
||||
it('renders correct total weighted grade when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
setTabData({
|
||||
section_scores: [
|
||||
{
|
||||
@@ -678,16 +647,12 @@ describe('Progress Tab', () => {
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
has_graded_assignment: true,
|
||||
learner_has_access: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
percent_graded: 1.0,
|
||||
problem_scores: [{
|
||||
earned: 1,
|
||||
possible: 2,
|
||||
}],
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -695,24 +660,15 @@ describe('Progress Tab', () => {
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@98765',
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 0,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
override: {
|
||||
system: 'PROCTORING',
|
||||
reason: 'Suspicious activity',
|
||||
},
|
||||
percent_graded: 1.0,
|
||||
problem_scores: [{
|
||||
earned: 0,
|
||||
possible: 1,
|
||||
}],
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
show_grades: false,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
@@ -721,14 +677,7 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
const problemScoreDrawerToggle = screen.getByRole('button', { name: 'Toggle individual problem scores for Second subsection' });
|
||||
expect(problemScoreDrawerToggle).toBeInTheDocument();
|
||||
|
||||
// Open the problem score drawer
|
||||
fireEvent.click(problemScoreDrawerToggle);
|
||||
|
||||
expect(screen.getByText(messages.sectionGradeOverridden.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -742,8 +691,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
it('sends event on click of subsection link', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
const subsectionLink = screen.getByRole('link', { name: 'First subsection' });
|
||||
@@ -759,8 +708,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
it('sends event on click of course outline link', async () => {
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
|
||||
|
||||
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
|
||||
@@ -800,7 +749,22 @@ describe('Progress Tab', () => {
|
||||
|
||||
describe('Certificate Status', () => {
|
||||
beforeAll(() => {
|
||||
global.innerWidth = breakpoints.large.minWidth;
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => {
|
||||
const matches = !!(query === 'screen and (min-width: 992px)');
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrolled user', () => {
|
||||
@@ -959,6 +923,49 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays download link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'earned_downloadable',
|
||||
});
|
||||
|
||||
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
|
||||
fireEvent.click(downloadCertificateLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'earned_downloadable',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays webview link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
@@ -1180,114 +1187,6 @@ describe('Progress Tab', () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
certificate_data: undefined,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: true,
|
||||
certificate_data: undefined,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows certificate_available_date if learner is passing', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: true,
|
||||
certificate_data: {
|
||||
cert_status: 'earned_but_not_available',
|
||||
certificate_available_date: overmorrow.toISOString(),
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Certificate status'));
|
||||
expect(screen.getByText(
|
||||
overmorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
{ exact: false },
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credit Information', () => {
|
||||
it('renders credit information when provided', async () => {
|
||||
setTabData({
|
||||
credit_course_requirements: {
|
||||
eligibility_status: 'eligible',
|
||||
requirements: [
|
||||
{
|
||||
namespace: 'proctored_exam',
|
||||
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
|
||||
display_name: 'Proctored Mid Term Exam',
|
||||
criteria: {},
|
||||
reason: {},
|
||||
status: null,
|
||||
status_date: '2015-06-26 11:07:42',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
namespace: 'grade',
|
||||
name: 'i4x://edX/DemoX/proctoring-block/final_uuid',
|
||||
display_name: 'Minimum Passing Grade',
|
||||
criteria: { min_grade: 0.8 },
|
||||
reason: { final_grade: 0.95 },
|
||||
status: 'satisfied',
|
||||
status_date: '2015-06-26 11:07:44',
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grades & Credit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Requirements for course credit')).toBeInTheDocument();
|
||||
expect(screen.getByText('You have met the requirements for credit in this course.', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('Proctored Mid Term Exam:')).toBeInTheDocument();
|
||||
// 80% comes from the criteria.minGrade being 0.8
|
||||
expect(screen.getByText('Minimum grade for credit (80%):')).toBeInTheDocument();
|
||||
// Completed because the grade requirement has been satisfied
|
||||
expect(screen.getByText('Completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render credit information when it is not provided', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Grades & Credit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Requirements for course credit.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Access expiration masquerade banner', () => {
|
||||
@@ -1303,7 +1202,7 @@ describe('Progress Tab', () => {
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
|
||||
});
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||
@@ -1316,7 +1215,7 @@ describe('Progress Tab', () => {
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2020', { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1332,7 +1231,7 @@ describe('Progress Tab', () => {
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2999', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
|
||||
});
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
setMetadata({
|
||||
@@ -1344,7 +1243,7 @@ describe('Progress Tab', () => {
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2999', { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user