Compare commits

..

29 Commits

Author SHA1 Message Date
Chris Deery
6c9899be5e feat: [AA-906] A11y changes for buttons to be seen as radio buttons 2021-10-01 14:35:20 -04:00
Chris Deery
93873cd845 feat: [AA-906] A11y changes for buttons to be seen as radio buttons 2021-10-01 14:22:58 -04:00
Chris Deery
3def37d28c feat: [AA-906] Merge conflicts with Master 2021-09-30 13:48:10 -04:00
Chris Deery
d7e7b8c873 feat: [AA-906] Additional tests 2021-09-30 11:56:40 -04:00
Chris Deery
b401f7bb34 feat: [AA-906] missed one review comment 2021-09-30 11:56:40 -04:00
Chris Deery
952d5d89e7 feat: [AA-906] Cleanup and add some Unit tests 2021-09-30 11:56:40 -04:00
Chris Deery
15d9c9f1bd feat: [AA-906] rebase from master 2021-09-30 11:56:19 -04:00
Chris Deery
b5e22c983d feat: [AA-906] update snapshot 2021-09-30 11:52:55 -04:00
Chris Deery
4d3b68d287 feat: [AA-906] implemented review feedback 2021-09-30 11:52:55 -04:00
Chris Deery
2607cec626 feat: [AA-906] updated redux.test.js.snap with new data for goals 2021-09-30 11:52:55 -04:00
Chris Deery
ec18af1ec0 feat: [AA-906] Front end for Number of Days goal setting
fix errors from AccountActivationAlert.jsx
2021-09-30 11:52:55 -04:00
Chris Deery
ee62aab0e7 feat: [AA-906] Front end for Number of Days goal setting
fix undefined access
2021-09-30 11:52:55 -04:00
Chris Deery
3cb55b320a feat: [AA-906] Front end for Number of Days goal setting
remove console.log calls
2021-09-30 11:52:55 -04:00
Chris Deery
d2e26bf8cf feat: [AA-906] Front end for Number of Days goal setting
fix improper I18n messages
2021-09-30 11:52:55 -04:00
Chris Deery
95fb4ec859 feat: [AA-906] Front end for Number of Days goal setting
Implement days per week buttons
link buttons to api to set value
Implement subscribe button
link subscribe button to api
tweak CSS
Add new icons for flags
Add new function for updating weekly goals
2021-09-30 11:52:55 -04:00
Chris Deery
03f0ec4aec feat: [AA-906] Front end for Number of Days goal setting
Consume flag for course_goals[number_of_days_goals_enabled]
Added StartResumeCard to outlineTab
Move the Start/Resume course button to above the Intro
Put the start/resume in a card with a text block
2021-09-30 11:52:55 -04:00
Chris Deery
9dc447fa85 feat: [AA-906] Additional tests 2021-09-29 09:41:50 -04:00
Chris Deery
0286304c39 feat: [AA-906] missed one review comment 2021-09-27 09:02:15 -04:00
Chris Deery
d88c4ade78 feat: [AA-906] Cleanup and add some Unit tests 2021-09-27 08:43:09 -04:00
Chris Deery
e3b8143677 feat: [AA-906] more review updates and refactoring 2021-09-21 17:29:10 -04:00
Chris Deery
09384b317e feat: [AA-906] update snapshot 2021-09-21 15:15:03 -04:00
Chris Deery
2172ea4041 feat: [AA-906] implemented review feedback 2021-09-21 15:02:26 -04:00
Chris Deery
7e3aee7147 feat: [AA-906] updated redux.test.js.snap with new data for goals 2021-09-20 15:29:38 -04:00
Chris Deery
4f187390a0 feat: [AA-906] Front end for Number of Days goal setting
fix errors from AccountActivationAlert.jsx
2021-09-20 15:10:44 -04:00
Chris Deery
5b3053d3bb feat: [AA-906] Front end for Number of Days goal setting
fix undefined access
2021-09-17 17:04:30 -04:00
Chris Deery
dae1e03e73 feat: [AA-906] Front end for Number of Days goal setting
remove console.log calls
2021-09-17 12:03:51 -04:00
Chris Deery
a32c79984d feat: [AA-906] Front end for Number of Days goal setting
fix improper I18n messages
2021-09-17 11:51:34 -04:00
Chris Deery
8d0596a32e feat: [AA-906] Front end for Number of Days goal setting
Implement days per week buttons
link buttons to api to set value
Implement subscribe button
link subscribe button to api
tweak CSS
Add new icons for flags
Add new function for updating weekly goals
2021-09-17 11:40:12 -04:00
Chris Deery
91b1229a29 feat: [AA-906] Front end for Number of Days goal setting
Consume flag for course_goals[number_of_days_goals_enabled]
Added StartResumeCard to outlineTab
Move the Start/Resume course button to above the Intro
Put the start/resume in a card with a text block
2021-09-09 09:56:31 -04:00
271 changed files with 12221 additions and 41650 deletions

11
.env
View File

@@ -2,20 +2,14 @@
# If you add a new learning MFE-specific variable, please note it there! # If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production' NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME='' ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL='' BASE_URL=''
CONTACT_URL='' CONTACT_URL=''
CREDENTIALS_BASE_URL='' CREDENTIALS_BASE_URL=''
CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH='' CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL='' DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
ECOMMERCE_BASE_URL='' ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='' ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
FAVICON_URL=''
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL='' INSIGHTS_BASE_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME='' LANGUAGE_PREFERENCE_COOKIE_NAME=''
@@ -25,13 +19,12 @@ LOGOUT_URL=''
LOGO_URL='' LOGO_URL=''
LOGO_TRADEMARK_URL='' LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL='' LOGO_WHITE_URL=''
LEGACY_THEME_NAME='' FAVICON_URL=''
MARKETING_SITE_BASE_URL='' MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL='' ORDER_HISTORY_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='' REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL='' SEARCH_CATALOG_URL=''
SEGMENT_KEY='' SEGMENT_KEY=''
SESSION_COOKIE_DOMAIN=''
SITE_NAME='' SITE_NAME=''
SOCIAL_UTM_MILESTONE_CAMPAIGN='' SOCIAL_UTM_MILESTONE_CAMPAIGN=''
STUDIO_BASE_URL='' STUDIO_BASE_URL=''
@@ -43,3 +36,5 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG='' TWITTER_HASHTAG=''
TWITTER_URL='' TWITTER_URL=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
SESSION_COOKIE_DOMAIN=''
ENABLE_JUMPNAV='true'

View File

@@ -2,20 +2,14 @@
# If you add a new learning MFE-specific variable, please note it there! # If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development' NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000' BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact' CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150' CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000' LMS_BASE_URL='http://localhost:18000'
@@ -24,7 +18,7 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME='' FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
@@ -43,3 +37,4 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost' SESSION_COOKIE_DOMAIN='localhost'
ENABLE_JUMPNAV='true'

View File

@@ -2,20 +2,14 @@
# If you add a new learning MFE-specific variable, please note it there! # If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='test' NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000' BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact' CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150' CREDENTIALS_BASE_URL='http://localhost:18150'
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130' ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734' ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX='' IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000' LMS_BASE_URL='http://localhost:18000'
@@ -24,7 +18,7 @@ LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LEGACY_THEME_NAME='' FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
@@ -42,3 +36,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_JUMPNAV='true'

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

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

1
.husky/_/.gitignore vendored
View File

@@ -1 +0,0 @@
*

View File

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

View File

@@ -1,9 +1,8 @@
[main] [main]
host = https://www.transifex.com host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-learning] [edx-platform.frontend-app-learning]
file_filter = src/i18n/messages/<lang>.json file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json source_file = src/i18n/transifex_input.json
source_lang = en source_lang = en
type = KEYVALUEJSON type = KEYVALUEJSON

View File

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

View File

@@ -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 .. |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 :target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational .. |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 Development
----------- -----------
@@ -23,7 +23,7 @@ Development
Start Devstack Start Devstack
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it. To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms`` - 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. - 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 Deployment
---------- ----------
@@ -71,15 +71,6 @@ as documented in the Open edX Developer Guide under
The learning micro-frontend also supports the following additional variables: The learning micro-frontend also supports the following additional variables:
CREDIT_HELP_LINK_URL
A link to resources to help explain what course credit is and how to earn it.
ENABLE_JUMPNAV
Enables the new Jump Navigation feature in the course breadcrumbs, defaulted to the string 'true'.
Disable to have simple hyperlinks for breadcrumbs. Setting it to any other value but 'true' ('false','I love flags', 'etc' would disable the Jumpnav).
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
https://openedx.atlassian.net/browse/TNL-8678
SOCIAL_UTM_MILESTONE_CAMPAIGN SOCIAL_UTM_MILESTONE_CAMPAIGN
This value is passed as the ``utm_campaign`` parameter for social-share This value is passed as the ``utm_campaign`` parameter for social-share
links when celebrating learning milestones in the course. Optional. links when celebrating learning milestones in the course. Optional.
@@ -119,3 +110,8 @@ TWITTER_URL
Example: https://twitter.com/edXOnline 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

40752
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,16 @@
"description": "Frontend learning application.", "description": "Frontend learning application.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/openedx/frontend-app-learning.git" "url": "git+https://github.com/edx/frontend-app-learning.git"
}, },
"browserslist": [ "browserslist": [
"extends @edx/browserslist-config" "last 2 versions",
"ie 11"
], ],
"scripts": { "scripts": {
"build": "fedx-scripts webpack", "build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null", "i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .", "lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .", "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install", "prepare": "husky install",
@@ -21,59 +23,60 @@
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-learning#readme", "homepage": "https://github.com/edx/frontend-app-learning#readme",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"bugs": { "bugs": {
"url": "https://github.com/openedx/frontend-app-learning/issues" "url": "https://github.com/edx/frontend-app-learning/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.1.0", "@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-component-header": "3.1.0", "@edx/frontend-enterprise-utils": "1.0.0",
"@edx/frontend-lib-special-exams": "2.1.0", "@edx/frontend-lib-special-exams": "1.13.2",
"@edx/frontend-platform": "2.5.1", "@edx/frontend-platform": "1.12.7",
"@edx/paragon": "19.18.3", "@edx/paragon": "16.13.3",
"@fortawesome/fontawesome-svg-core": "1.3.0", "@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.18", "@fortawesome/react-fontawesome": "0.1.15",
"@popperjs/core": "2.11.5", "@pact-foundation/pact": "9.16.1",
"@reduxjs/toolkit": "1.8.1", "@reduxjs/toolkit": "1.6.1",
"classnames": "2.3.1", "classnames": "2.3.1",
"core-js": "3.22.2", "core-js": "3.16.4",
"history": "^5.3.0", "js-cookie": "2.2.1",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0", "lodash.camelcase": "4.3.0",
"prop-types": "15.8.1", "prop-types": "15.7.2",
"query-string": "^7.1.1", "react": "17.0.2",
"react": "16.14.0", "react-break": "1.3.2",
"react-dom": "16.14.0", "react-dom": "17.0.2",
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
"react-redux": "7.2.8", "react-redux": "7.2.5",
"react-router": "5.2.1", "react-router": "5.2.1",
"react-router-dom": "5.3.0", "react-router-dom": "5.2.1",
"react-share": "4.4.0", "react-share": "4.4.0",
"redux": "4.1.2", "redux": "4.1.1",
"regenerator-runtime": "0.13.9", "regenerator-runtime": "0.13.9",
"reselect": "4.1.5", "reselect": "4.0.0",
"truncate-html": "1.0.4", "truncate-html": "1.0.4",
"util": "0.12.4" "util": "0.12.4"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.0.2", "@edx/frontend-build": "8.0.4",
"@edx/frontend-build": "9.1.4", "@testing-library/dom": "7.16.3",
"@edx/reactifex": "2.0.1", "@testing-library/jest-dom": "5.14.1",
"@pact-foundation/pact": "9.17.3", "@testing-library/react": "10.3.0",
"@testing-library/jest-dom": "5.16.4", "@testing-library/user-event": "13.2.1",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0", "axios-mock-adapter": "1.20.0",
"es-check": "6.2.1", "codecov": "3.8.3",
"husky": "7.0.4", "es-check": "6.0.0",
"jest": "27.5.1", "glob": "7.1.7",
"husky": "7.0.2",
"jest": "27.0.6",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.1.0" "rosie": "2.1.0"
} }
} }

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -65,7 +65,6 @@ function AccessExpirationAlert({ intl, payload }) {
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.deadline" id="learning.accessExpiration.deadline"
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site." defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
values={{ values={{
date: ( date: (
<FormattedDate <FormattedDate
@@ -98,7 +97,6 @@ function AccessExpirationAlert({ intl, payload }) {
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.header" id="learning.accessExpiration.header"
defaultMessage="Audit Access Expires {date}" defaultMessage="Audit Access Expires {date}"
description="Headline for auditing deadline"
values={{ values={{
date: ( date: (
<FormattedDate <FormattedDate
@@ -117,7 +115,6 @@ function AccessExpirationAlert({ intl, payload }) {
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.body" id="learning.accessExpiration.body"
defaultMessage="You lose all access to this course, including your progress, on {date}." defaultMessage="You lose all access to this course, including your progress, on {date}."
description="Message body to tell learner the consequences of course expiration."
values={{ values={{
date: ( date: (
<FormattedDate <FormattedDate

View File

@@ -16,7 +16,6 @@ function AccessExpirationMasqueradeBanner({ payload }) {
<FormattedMessage <FormattedMessage
id="instructorToolbar.pageBanner.courseHasExpired" id="instructorToolbar.pageBanner.courseHasExpired"
defaultMessage="This learner no longer has access to this course. Their access expired on {date}." 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={{ values={{
date: <FormattedDate date: <FormattedDate
key="instructorToolbar.pageBanner.accessExpirationDate" key="instructorToolbar.pageBanner.accessExpirationDate"

View File

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

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Alert, Hyperlink } from '@edx/paragon';
import { WarningFilled } from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';
function ActiveEnterpriseAlert({ intl, payload }) {
const { text, courseId } = payload;
const changeActiveEnterprise = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
`${global.location.origin}/course/${courseId}/home`,
)}`
}
>
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
</Hyperlink>
);
return (
<Alert variant="warning" icon={WarningFilled}>
{text}
<FormattedMessage
id="learning.activeEnterprise.alert"
description="Prompts the user to log-in with the correct enterprise to access the course content."
defaultMessage=" {changeActiveEnterprise}."
values={{
changeActiveEnterprise,
}}
/>
</Alert>
);
}
ActiveEnterpriseAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
text: PropTypes.string,
courseId: PropTypes.string,
}).isRequired,
};
export default injectIntl(ActiveEnterpriseAlert);

View File

@@ -1,27 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
initializeTestStore, render, screen,
} from '../../setupTest';
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
describe('ActiveEnterpriseAlert', () => {
const mockData = {
payload: {
text: 'test message',
courseId: 'test-course-id',
},
};
beforeAll(async () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
});
it('Shows alert message and links', () => {
render(<ActiveEnterpriseAlert {...mockData} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute(
'href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`,
);
});
});

View File

@@ -1,28 +0,0 @@
import React, { useMemo } from 'react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
export default function useActiveEnterpriseAlert(courseId) {
const { courseAccess } = useModel('courseHomeMeta', courseId);
/**
* This alert should render if
* 1. course access code is incorrect_active_enterprise
*/
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
const payload = {
text: courseAccess && courseAccess.userMessage,
courseId,
};
useAlert(isVisible, {
code: 'clientActiveEnterpriseAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
payload: useMemo(() => payload, Object.values(payload).sort()),
});
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
}

View File

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

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
changeActiveEnterpriseLowercase: {
id: 'learning.activeEnterprise.change.alert',
defaultMessage: 'change enterprise now',
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
},
});
export default messages;

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { import {
FormattedDate, FormattedDate,
FormattedMessage, FormattedMessage,
FormattedRelativeTime, FormattedRelative,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
@@ -26,7 +26,7 @@ function CourseStartAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = ( const timeRemaining = (
<FormattedRelativeTime <FormattedRelative
key="timeRemaining" key="timeRemaining"
value={startDate} value={startDate}
{...timezoneFormatArgs} {...timezoneFormatArgs}
@@ -86,7 +86,6 @@ function CourseStartAlert({ payload }) {
<FormattedMessage <FormattedMessage
id="learning.outline.alert.end.calendar" id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!" defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/> />
</Alert> </Alert>
); );

View File

@@ -22,7 +22,6 @@ function CourseStartMasqueradeBanner({ payload }) {
<FormattedMessage <FormattedMessage
id="instructorToolbar.pageBanner.courseHasNotStarted" id="instructorToolbar.pageBanner.courseHasNotStarted"
defaultMessage="This learner does not yet have access to this course. The course starts on {date}." 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={{ values={{
date: <FormattedDate date: <FormattedDate
key="instructorToolbar.pageBanner.courseStartDate" key="instructorToolbar.pageBanner.courseStartDate"

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ function CourseTabsNavigation({
activeTabSlug, className, tabs, intl, activeTabSlug, className, tabs, intl,
}) { }) {
return ( return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}> <div className={classNames('course-tabs-navigation', className)}>
<div className="container-xl"> <div className="container-xl">
<Tabs <Tabs
className="nav-underline-tabs" className="nav-underline-tabs"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory'; import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
Factory.define('courseHomeMetadata') Factory.define('courseHomeMetadata')
@@ -8,9 +9,7 @@ Factory.define('courseHomeMetadata')
title: 'Demonstration Course', title: 'Demonstration Course',
is_self_paced: false, is_self_paced: false,
is_enrolled: false, is_enrolled: false,
is_staff: false, can_load_courseware: false,
can_view_certificate: true,
celebrations: null,
course_access: { course_access: {
additional_context_user_message: null, additional_context_user_message: null,
developer_message: null, developer_message: null,
@@ -19,106 +18,6 @@ Factory.define('courseHomeMetadata')
user_fragment: null, user_fragment: null,
user_message: null, user_message: null,
}, },
number: 'DemoX',
original_user_is_staff: false,
org: 'edX',
start: '2013-02-05T05:00:00Z', start: '2013-02-05T05:00:00Z',
user_timezone: 'UTC', user_timezone: 'UTC',
username: 'MockUser', });
verified_mode: {
access_expiration_date: null,
currency: 'USD',
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
sku: '8CF08E5',
price: 149,
currency_symbol: '$',
},
})
.attr(
'tabs', ['id', 'host'], (id, host) => [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{
courseId: id,
host,
path: 'course/',
},
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{
courseId: id,
host,
path: 'discussion/forum/',
},
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{
courseId: id,
host,
path: 'course_wiki',
},
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{
courseId: id,
host,
path: 'progress',
},
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{
courseId: id,
host,
path: 'instructor',
},
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{
courseId: id,
host,
path: 'dates',
},
),
],
);

View File

@@ -35,11 +35,12 @@ Factory.define('outlineTabData')
cert_status: null, cert_status: null,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
course_goals: { course_goals: {
goal_options: [], goal_options: [],
selected_goal: null, selected_goal: null,
weekly_learning_goal_enabled: false, number_of_days_goals_enabled: false,
days_per_week: null, days_per_week: null,
subscribed_to_reminders: null, subscribed_to_reminders: null,
}, },

View File

@@ -17,7 +17,6 @@ Factory.define('progressTabData')
percent: 1, percent: 1,
is_passing: true, is_passing: true,
}, },
credit_course_requirements: null,
section_scores: [ section_scores: [
{ {
display_name: 'First section', display_name: 'First section',

View File

@@ -5,6 +5,7 @@ Factory.define('upgradeNotificationData')
.option('dateBlocks', []) .option('dateBlocks', [])
.option('offer', null) .option('offer', null)
.option('userTimezone', null) .option('userTimezone', null)
.option('accessExpiration', null)
.option('contentTypeGatingEnabled', false) .option('contentTypeGatingEnabled', false)
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course') .attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
.attr('upsellPageName', 'test') .attr('upsellPageName', 'test')
@@ -17,9 +18,4 @@ Factory.define('upgradeNotificationData')
upgradeUrl: `${host}/dashboard`, upgradeUrl: `${host}/dashboard`,
})) }))
.attr('org', 'edX') .attr('org', 'edX')
.attrs({
accessExpiration: {
expiration_date: '1950-07-13T02:04:49.040006Z',
},
})
.attr('timeOffsetMillis', 0); .attr('timeOffsetMillis', 0);

View File

@@ -3,9 +3,8 @@
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = ` exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object { Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined, "targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
@@ -15,14 +14,12 @@ Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"sequenceId": null, "sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canViewCertificate": true, "canLoadCourseware": false,
"celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
"developerMessage": null, "developerMessage": null,
@@ -31,7 +28,7 @@ Object {
"userFragment": null, "userFragment": null,
"userMessage": null, "userMessage": null,
}, },
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false, "isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
@@ -44,49 +41,45 @@ Object {
Object { Object {
"slug": "outline", "slug": "outline",
"title": "Course", "title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
}, },
Object { Object {
"slug": "discussion", "slug": "discussion",
"title": "Discussion", "title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
}, },
Object { Object {
"slug": "wiki", "slug": "wiki",
"title": "Wiki", "title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
}, },
Object { Object {
"slug": "progress", "slug": "progress",
"title": "Progress", "title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
}, },
Object { Object {
"slug": "instructor", "slug": "instructor",
"title": "Instructor", "title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
}, },
Object { Object {
"slug": "dates", "slug": "dates",
"title": "Dates", "title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
}, },
], ],
"title": "Demonstration Course", "title": "Demonstration Course",
"userTimezone": "UTC", "userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object { "verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$", "currencySymbol": "$",
"price": 149, "price": 10,
"sku": "8CF08E5", "upgradeUrl": "test",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
}, },
}, },
}, },
"dates": Object { "dates": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"courseDateBlocks": Array [ "courseDateBlocks": Array [
Object { Object {
"date": "2020-05-01T17:59:41Z", "date": "2020-05-01T17:59:41Z",
@@ -300,7 +293,7 @@ Object {
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5", "verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
}, },
"hasEnded": false, "hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true, "learnerIsFullAccess": true,
}, },
}, },
@@ -308,22 +301,14 @@ Object {
"recommendations": Object { "recommendations": Object {
"recommendationsStatus": "loading", "recommendationsStatus": "loading",
}, },
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
} }
`; `;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = ` exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object { Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined, "targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
@@ -333,14 +318,12 @@ Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"sequenceId": null, "sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canViewCertificate": true, "canLoadCourseware": false,
"celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
"developerMessage": null, "developerMessage": null,
@@ -349,7 +332,7 @@ Object {
"userFragment": null, "userFragment": null,
"userMessage": null, "userMessage": null,
}, },
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false, "isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
@@ -362,61 +345,58 @@ Object {
Object { Object {
"slug": "outline", "slug": "outline",
"title": "Course", "title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
}, },
Object { Object {
"slug": "discussion", "slug": "discussion",
"title": "Discussion", "title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
}, },
Object { Object {
"slug": "wiki", "slug": "wiki",
"title": "Wiki", "title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
}, },
Object { Object {
"slug": "progress", "slug": "progress",
"title": "Progress", "title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
}, },
Object { Object {
"slug": "instructor", "slug": "instructor",
"title": "Instructor", "title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
}, },
Object { Object {
"slug": "dates", "slug": "dates",
"title": "Dates", "title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
}, },
], ],
"title": "Demonstration Course", "title": "Demonstration Course",
"userTimezone": "UTC", "userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object { "verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$", "currencySymbol": "$",
"price": 149, "price": 10,
"sku": "8CF08E5", "upgradeUrl": "test",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
}, },
}, },
}, },
"outline": Object { "outline": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null, "accessExpiration": null,
"canShowUpgradeSock": false, "canShowUpgradeSock": false,
"certData": Object { "certData": Object {
"certStatus": null, "certStatus": null,
"certWebViewUrl": null, "certWebViewUrl": null,
"certificateAvailableDate": null, "certificateAvailableDate": null,
"downloadUrl": null,
}, },
"courseBlocks": Object { "courseBlocks": Object {
"courses": Object { "courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object { "block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false, "hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [ "sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
], ],
@@ -426,7 +406,7 @@ Object {
"sections": Object { "sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object { "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false, "complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false, "resumeBlock": false,
"sequenceIds": Array [ "sequenceIds": Array [
@@ -444,6 +424,7 @@ Object {
"effortTime": 15, "effortTime": 15,
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true, "showLink": true,
"title": "Title of Sequence", "title": "Title of Sequence",
@@ -453,9 +434,9 @@ Object {
"courseGoals": Object { "courseGoals": Object {
"daysPerWeek": null, "daysPerWeek": null,
"goalOptions": Array [], "goalOptions": Array [],
"numberOfDaysGoalsEnabled": false,
"selectedGoal": null, "selectedGoal": null,
"subscribedToReminders": null, "subscribedToReminders": null,
"weeklyLearningGoalEnabled": false,
}, },
"courseTools": Array [ "courseTools": Array [
Object { Object {
@@ -472,7 +453,6 @@ Object {
"datesWidget": Object { "datesWidget": Object {
"courseDateBlocks": Array [], "courseDateBlocks": Array [],
}, },
"enableProctoredExams": undefined,
"enrollAlert": Object { "enrollAlert": Object {
"canEnroll": true, "canEnroll": true,
"extraText": "Contact the administrator.", "extraText": "Contact the administrator.",
@@ -481,7 +461,7 @@ Object {
"handoutsHtml": "<ul><li>Handout 1</li></ul>", "handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined, "hasEnded": undefined,
"hasScheduledContent": null, "hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"offer": null, "offer": null,
"resumeCourse": Object { "resumeCourse": Object {
"hasVisitedCourse": false, "hasVisitedCourse": false,
@@ -504,22 +484,14 @@ Object {
"recommendations": Object { "recommendations": Object {
"recommendationsStatus": "loading", "recommendationsStatus": "loading",
}, },
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
} }
`; `;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = ` exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
Object { Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"targetUserId": undefined, "targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
@@ -529,14 +501,12 @@ Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"sequenceId": null, "sequenceId": null,
"sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canViewCertificate": true, "canLoadCourseware": false,
"celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
"developerMessage": null, "developerMessage": null,
@@ -545,7 +515,7 @@ Object {
"userFragment": null, "userFragment": null,
"userMessage": null, "userMessage": null,
}, },
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false, "isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
@@ -558,49 +528,45 @@ Object {
Object { Object {
"slug": "outline", "slug": "outline",
"title": "Course", "title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course/", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
}, },
Object { Object {
"slug": "discussion", "slug": "discussion",
"title": "Discussion", "title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
}, },
Object { Object {
"slug": "wiki", "slug": "wiki",
"title": "Wiki", "title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/course_wiki", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
}, },
Object { Object {
"slug": "progress", "slug": "progress",
"title": "Progress", "title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/progress", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
}, },
Object { Object {
"slug": "instructor", "slug": "instructor",
"title": "Instructor", "title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/instructor", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
}, },
Object { Object {
"slug": "dates", "slug": "dates",
"title": "Dates", "title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/dates", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
}, },
], ],
"title": "Demonstration Course", "title": "Demonstration Course",
"userTimezone": "UTC", "userTimezone": "UTC",
"username": "MockUser",
"verifiedMode": Object { "verifiedMode": Object {
"accessExpirationDate": null,
"currency": "USD",
"currencySymbol": "$", "currencySymbol": "$",
"price": 149, "price": 10,
"sku": "8CF08E5", "upgradeUrl": "test",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
}, },
}, },
}, },
"progress": Object { "progress": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null, "accessExpiration": null,
"certificateData": Object {}, "certificateData": Object {},
"completionSummary": Object { "completionSummary": Object {
@@ -612,9 +578,9 @@ Object {
"isPassing": true, "isPassing": true,
"letterGrade": "pass", "letterGrade": "pass",
"percent": 1, "percent": 1,
"visiblePercent": 1,
}, },
"courseId": "course-v1:edX+DemoX+Demo_Course", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"creditCourseRequirements": null,
"end": "3027-03-31T00:00:00Z", "end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit", "enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false, "gradesFeatureIsFullyLocked": false,
@@ -622,7 +588,7 @@ Object {
"gradingPolicy": Object { "gradingPolicy": Object {
"assignmentPolicies": Array [ "assignmentPolicies": Array [
Object { Object {
"averageGrade": "1.00", "averageGrade": 1,
"numDroppable": 1, "numDroppable": 1,
"shortLabel": "HW", "shortLabel": "HW",
"type": "Homework", "type": "Homework",
@@ -635,7 +601,7 @@ Object {
}, },
}, },
"hasScheduledContent": false, "hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course", "id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionScores": Array [ "sectionScores": Array [
Object { Object {
"displayName": "First section", "displayName": "First section",
@@ -706,12 +672,5 @@ Object {
"recommendations": Object { "recommendations": Object {
"recommendationsStatus": "loading", "recommendationsStatus": "loading",
}, },
"tours": Object {
"showCoursewareTour": false,
"showExistingUserCourseHomeTour": false,
"showNewUserCourseHomeModal": false,
"showNewUserCourseHomeTour": false,
"toursEnabled": false,
},
} }
`; `;

View File

@@ -15,10 +15,7 @@ const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) =
let averageGrade = 0; let averageGrade = 0;
let weightedGrade = 0; let weightedGrade = 0;
if (points.length) { if (points.length) {
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately averageGrade = points.reduce((a, b) => a + b, 0) / points.length;
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
// exists in edx-platform.
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(2);
weightedGrade = averageGrade * assignmentWeight; weightedGrade = averageGrade * assignmentWeight;
} }
return { averageGrade, weightedGrade }; return { averageGrade, weightedGrade };
@@ -90,21 +87,14 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
}); });
} }
/** function normalizeCourseHomeCourseMetadata(metadata) {
* Tweak the metadata for consistency
* @param metadata the data to normalize
* @param rootSlug either 'courseware' or 'outline' depending on the context
* @returns {Object} The normalized metadata
*/
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata); const data = camelCaseObject(metadata);
return { return {
...data, ...data,
tabs: data.tabs.map(tab => ({ tabs: data.tabs.map(tab => ({
// The API uses "courseware" as a slug for both courseware and the outline tab. // The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
// If needed, we switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware. // use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId, slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
title: tab.title, title: tab.title,
url: tab.url, url: tab.url,
})), })),
@@ -148,9 +138,12 @@ export function normalizeOutlineBlocks(courseId, blocks) {
effortTime: block.effort_time, effortTime: block.effort_time,
icon: block.icon, icon: block.icon,
id: block.id, id: block.id,
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable legacyWebUrl: block.legacy_web_url,
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves). // The presence of an legacy URL for the sequence indicates that we want this
showLink: !!block.lms_web_url, // sequence to be a clickable link in the outline (even though, if the new
// courseware experience is active, we will ignore `legacyWebUrl` and build a
// link to the MFE ourselves).
showLink: !!block.legacy_web_url,
title: block.display_name, title: block.display_name,
}; };
break; break;
@@ -186,11 +179,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
return models; return models;
} }
export async function getCourseHomeCourseMetadata(courseId, rootSlug) { export async function getCourseHomeCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url); url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data, rootSlug); return normalizeCourseHomeCourseMetadata(data);
} }
// For debugging purposes, you might like to see a fully loaded dates tab. // For debugging purposes, you might like to see a fully loaded dates tab.
@@ -205,6 +198,10 @@ export async function getDatesTabData(courseId) {
return camelCaseObject(data); return camelCaseObject(data);
} catch (error) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining // 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. // courseAccess in the metadata call, so just ignore this status for now.
@@ -232,6 +229,16 @@ export async function getProgressTabData(courseId, targetUserId) {
camelCasedData.sectionScores, 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. // 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") // 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. // 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) { export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference // 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 // 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) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now(); let { tabData } = {};
const tabData = await getAuthenticatedHttpClient().get(url); let requestTime = Date.now();
const responseTime = 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 { const {
data, data,
@@ -338,7 +343,6 @@ export async function getOutlineTabData(courseId) {
const courseTools = camelCaseObject(data.course_tools); const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info); const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget); const datesWidget = camelCaseObject(data.dates_widget);
const enableProctoredExams = data.enable_proctored_exams;
const enrollAlert = camelCaseObject(data.enroll_alert); const enrollAlert = camelCaseObject(data.enroll_alert);
const enrollmentMode = data.enrollment_mode; const enrollmentMode = data.enrollment_mode;
const handoutsHtml = data.handouts_html; const handoutsHtml = data.handouts_html;
@@ -362,7 +366,6 @@ export async function getOutlineTabData(courseId) {
datesWidget, datesWidget,
enrollAlert, enrollAlert,
enrollmentMode, enrollmentMode,
enableProctoredExams,
handoutsHtml, handoutsHtml,
hasScheduledContent, hasScheduledContent,
hasEnded, hasEnded,
@@ -383,12 +386,12 @@ export async function postCourseDeadlines(courseId, model) {
}); });
} }
export async function deprecatedPostCourseGoals(courseId, goalKey) { export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`); const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey }); return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
} }
export async function postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) { export async function postWeeklyCourseGoals(courseId, daysPerWeek, subscribedToReminders) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`); const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, { return getAuthenticatedHttpClient().post(url.href, {
course_id: courseId, course_id: courseId,

View File

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

View File

@@ -58,6 +58,7 @@ describe('Course Home Service', () => {
sku: '8CF08E5', sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}), }),
can_load_courseware: boolean(true),
celebrations: like({ celebrations: like({
first_section: false, first_section: false,
streak_length_to_celebrate: null, streak_length_to_celebrate: null,
@@ -105,6 +106,7 @@ describe('Course Home Service', () => {
sku: '8CF08E5', sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}, },
canLoadCourseware: true,
celebrations: { celebrations: {
firstSection: false, firstSection: false,
streakLengthToCelebrate: null, streakLengthToCelebrate: null,
@@ -137,7 +139,7 @@ describe('Course Home Service', () => {
title: 'Demonstration Course', title: 'Demonstration Course',
username: 'edx', username: 'edx',
}; };
const response = await getCourseHomeCourseMetadata(courseId, 'outline'); const response = await getCourseHomeCourseMetadata(courseId);
expect(response).toBeTruthy(); expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData); expect(response).toEqual(normalizedTabData);
}); });

View File

@@ -136,7 +136,7 @@ describe('Data layer integration tests', () => {
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`; const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
axiosMock.onPost(goalUrl).reply(200, {}); axiosMock.onPost(goalUrl).reply(200, {});
await thunks.deprecatedSaveCourseGoal(courseId, 'unsure'); await thunks.saveCourseGoal(courseId, 'unsure');
expect(axiosMock.history.post[0].url).toEqual(goalUrl); expect(axiosMock.history.post[0].url).toEqual(goalUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`); expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}","goal_key":"unsure"}`);

View File

@@ -11,15 +11,11 @@ const slice = createSlice({
initialState: { initialState: {
courseStatus: 'loading', courseStatus: 'loading',
courseId: null, courseId: null,
proctoringPanelStatus: 'loading',
toastBodyText: null, toastBodyText: null,
toastBodyLink: null, toastBodyLink: null,
toastHeader: '', toastHeader: '',
}, },
reducers: { reducers: {
fetchProctoringInfoResolved: (state) => {
state.proctoringPanelStatus = LOADED;
},
fetchTabDenied: (state, { payload }) => { fetchTabDenied: (state, { payload }) => {
state.courseId = payload.courseId; state.courseId = payload.courseId;
state.courseStatus = DENIED; state.courseStatus = DENIED;
@@ -51,7 +47,6 @@ const slice = createSlice({
}); });
export const { export const {
fetchProctoringInfoResolved,
fetchTabDenied, fetchTabDenied,
fetchTabFailure, fetchTabFailure,
fetchTabRequest, fetchTabRequest,

View File

@@ -7,11 +7,10 @@ import {
getOutlineTabData, getOutlineTabData,
getProgressTabData, getProgressTabData,
postCourseDeadlines, postCourseDeadlines,
deprecatedPostCourseGoals, postCourseGoals,
postWeeklyLearningGoal, postWeeklyCourseGoals,
postDismissWelcomeMessage, postDismissWelcomeMessage,
postRequestCert, postRequestCert,
getLiveTabIframe,
} from './api'; } from './api';
import { import {
@@ -33,38 +32,46 @@ const eventTypes = {
export function fetchTab(courseId, tab, getTabData, targetUserId) { export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => { return async (dispatch) => {
dispatch(fetchTabRequest({ courseId })); dispatch(fetchTabRequest({ courseId }));
try { Promise.allSettled([
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline'); getCourseHomeCourseMetadata(courseId),
dispatch(addModel({ getTabData(courseId, targetUserId),
modelType: 'courseHomeMeta', ]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
model: { const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
id: courseId, const fetchedTabData = tabDataResult.status === 'fulfilled';
...courseHomeCourseMetadata,
}, if (fetchedCourseHomeCourseMetadata) {
})); dispatch(addModel({
const tabDataResult = getTabData && await getTabData(courseId, targetUserId); modelType: 'courseHomeMeta',
if (tabDataResult) { model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({ dispatch(addModel({
modelType: tab, modelType: tab,
model: { model: {
id: courseId, id: courseId,
...tabDataResult, ...tabDataResult.value,
}, },
})); }));
} else {
logError(tabDataResult.reason);
} }
// Disable the access-denied path for now - it caused a regression // Disable the access-denied path for now - it caused a regression
if (!courseHomeCourseMetadata.courseAccess.hasAccess) { if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId })); dispatch(fetchTabDenied({ courseId }));
} else if (tabDataResult || !getTabData) { } else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ dispatch(fetchTabSuccess({ courseId, targetUserId }));
courseId, } else {
targetUserId, dispatch(fetchTabFailure({ courseId }));
}));
} }
} catch (e) { });
dispatch(fetchTabFailure({ courseId }));
logError(e);
}
}; };
} }
@@ -80,14 +87,6 @@ export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData); return fetchTab(courseId, 'outline', getOutlineTabData);
} }
export function fetchLiveTab(courseId) {
return fetchTab(courseId, 'live', getLiveTabIframe);
}
export function fetchDiscussionTab(courseId) {
return fetchTab(courseId, 'discussion');
}
export function dismissWelcomeMessage(courseId) { export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId); return async () => postDismissWelcomeMessage(courseId);
} }
@@ -111,12 +110,12 @@ export function resetDeadlines(courseId, model, getTabData) {
}; };
} }
export async function deprecatedSaveCourseGoal(courseId, goalKey) { export async function saveCourseGoal(courseId, goalKey) {
return deprecatedPostCourseGoals(courseId, goalKey); return postCourseGoals(courseId, goalKey);
} }
export async function saveWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders) { export async function saveWeeklyCourseGoal(courseId, daysPerWeek, subscribedToReminders) {
return postWeeklyLearningGoal(courseId, daysPerWeek, subscribedToReminders); return postWeeklyCourseGoals(courseId, daysPerWeek, subscribedToReminders);
} }
export function processEvent(eventData, getTabData) { export function processEvent(eventData, getTabData) {

View File

@@ -51,7 +51,7 @@ describe('DatesTab', () => {
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) { function setMetadata(attributes, options) {
courseMetadata = Factory.build('courseHomeMetadata', attributes, options); courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
} }
@@ -140,7 +140,7 @@ describe('DatesTab', () => {
userEvent.hover(tipIcon); userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon); userEvent.unhover(tipIcon);
await waitForElementToBeRemoved(tooltip); // and it's gone again waitForElementToBeRemoved(tooltip); // and it's gone again
}); });
}); });
@@ -341,12 +341,12 @@ describe('DatesTab', () => {
it('redirects to the home page when unauthenticated', async () => { it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required'); 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 () => { it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required'); 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}`);
}); });
}); });
}); });

View File

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

View File

@@ -1,36 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
function DiscussionTab() {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
const history = useHistory();
const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({
'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId });
history.push(`${basePath}/${payload.path}`);
},
});
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
return (
<iframe
src={discussionsUrl}
className="d-flex w-100 border-0"
height={iFrameHeight}
style={{ minHeight: '60rem' }}
title="discussion"
/>
);
}
DiscussionTab.propTypes = {};
export default injectIntl(DiscussionTab);

View File

@@ -1,61 +0,0 @@
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { Route } from 'react-router';
import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages';
import {
initializeMockApp, messageEvent, screen, waitFor,
} from '../../setupTest';
import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { appendBrowserTimezoneToUrl } from '../../utils';
import { fetchDiscussionTab } from '../data/thunks';
import DiscussionTab from './DiscussionTab';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('DiscussionTab', () => {
let axiosMock;
let store;
let component;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/discussion">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
});
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
const { id: courseId } = courseMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
beforeEach(() => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
render(component);
});
it('resizes when it gets a size hint from iframe', async () => {
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
await waitFor(() => expect(screen.getByTitle('discussion'))
.toHaveAttribute('height', String(1234)));
});
});

View File

@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { LearningHeader as Header } from '@edx/frontend-component-header'; import { Header } from '../../course-header';
import PageLoading from '../../generic/PageLoading'; import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api'; import { unsubscribeFromCourseGoal } from '../data/api';
@@ -29,10 +28,6 @@ function GoalUnsubscribe({ intl }) {
setError(true); setError(true);
}, },
); );
// We unfortunately have no information about the user, course, org, or really anything
// as visiting this page is allowed to be done anonymously and without the context of the course.
// The token can be used to connect a user and course, it will just require some post-processing
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
}, []); // deps=[] to only run once }, []); // deps=[] to only run once
return ( return (

View File

@@ -36,9 +36,7 @@ function ResultPage({ courseTitle, error, intl }) {
<> <>
<UnsubscribeIcon className="text-primary" alt="" /> <UnsubscribeIcon className="text-primary" alt="" />
<div role="heading" aria-level="1" className="h2">{header}</div> <div role="heading" aria-level="1" className="h2">{header}</div>
<div className="row justify-content-center"> <div>{description}</div>
<div className="col-xl-7 col-12 p-0">{description}</div>
</div>
<Button variant="brand" href={`${getConfig().LMS_BASE_URL}/dashboard`} className="mt-4"> <Button variant="brand" href={`${getConfig().LMS_BASE_URL}/dashboard`} className="mt-4">
{intl.formatMessage(messages.goToDashboard)} {intl.formatMessage(messages.goToDashboard)}
</Button> </Button>

View File

@@ -4,32 +4,26 @@ const messages = defineMessages({
contactSupport: { contactSupport: {
id: 'learning.goals.unsubscribe.contact', id: 'learning.goals.unsubscribe.contact',
defaultMessage: 'contact support', defaultMessage: 'contact support',
description: 'Its shown as a suggestion or recommendation for learner when their unsubscribing request has failed',
}, },
description: { description: {
id: 'learning.goals.unsubscribe.description', id: 'learning.goals.unsubscribe.description',
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.', 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: { errorHeader: {
id: 'learning.goals.unsubscribe.errorHeader', id: 'learning.goals.unsubscribe.errorHeader',
defaultMessage: 'Something went wrong', defaultMessage: 'Something went wrong',
description: 'It indicate that the unsubscribing request has failed',
}, },
goToDashboard: { goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard', id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard', defaultMessage: 'Go to dashboard',
description: 'Anchor text for button that redirects to dashboard page',
}, },
header: { header: {
id: 'learning.goals.unsubscribe.header', id: 'learning.goals.unsubscribe.header',
defaultMessage: 'Youve unsubscribed from goal reminders', defaultMessage: 'Youve unsubscribed from goal reminders',
description: 'It indicate that the unsubscribing request was successful',
}, },
loading: { loading: {
id: 'learning.goals.unsubscribe.loading', id: 'learning.goals.unsubscribe.loading',
defaultMessage: 'Unsubscribing…', defaultMessage: 'Unsubscribing…',
description: 'Message shown when the unsubscribing request is processing',
}, },
}); });

View File

@@ -1,22 +0,0 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
function LiveTab() {
const { courseId } = useSelector(state => state.courseHome);
const liveModel = useSelector(state => state.models.live);
useEffect(() => {
const iframe = document.getElementById('lti-tab-embed');
if (iframe) {
iframe.className += ' vh-100 w-100 border-0';
}
}, []);
return (
<div
id="live_tab"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
/>
);
}
export default LiveTab;

View File

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

View File

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

View File

@@ -1,22 +1,24 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Button, Toast } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages'; import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates'; import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts'; import CourseHandouts from './widgets/CourseHandouts';
import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard'; import StartOrResumeCourseCard from './widgets/StartOrResumeCourseCard';
import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard'; import WeeklyLearningGoalCard from './widgets/WeeklyLearningGoalCard';
import CourseTools from './widgets/CourseTools'; import CourseTools from './widgets/CourseTools';
import { fetchOutlineTab } from '../data'; import { fetchOutlineTab } from '../data';
import genericMessages from '../../generic/messages';
import messages from './messages'; import messages from './messages';
import Section from './Section'; import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert'; import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification'; import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert'; import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert'; import useCertificateAvailableAlert from './alerts/certificate-status-alert';
@@ -35,13 +37,13 @@ import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) { function OutlineTab({ intl }) {
const { const {
courseId, courseId,
proctoringPanelStatus,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
isSelfPaced, isSelfPaced,
org, org,
title, title,
username,
userTimezone, userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
@@ -52,6 +54,7 @@ function OutlineTab({ intl }) {
sections, sections,
}, },
courseGoals: { courseGoals: {
goalOptions,
selectedGoal, selectedGoal,
weeklyLearningGoalEnabled, weeklyLearningGoalEnabled,
} = {}, } = {},
@@ -59,16 +62,16 @@ function OutlineTab({ intl }) {
datesWidget: { datesWidget: {
courseDateBlocks, courseDateBlocks,
}, },
enableProctoredExams, resumeCourse: {
url: resumeCourseUrl,
},
offer, offer,
timeOffsetMillis, timeOffsetMillis,
verifiedMode, verifiedMode,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const { const [deprecatedCourseGoalToDisplay, setDeprecatedCourseGoalToDisplay] = useState(selectedGoal);
marketingUrl, const [goalToastHeader, setGoalToastHeader] = useState('');
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false); const [expandAll, setExpandAll] = useState(false);
const eventProperties = { const eventProperties = {
@@ -110,25 +113,15 @@ function OutlineTab({ intl }) {
/** show post enrolment survey to only B2C learners */ /** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner'; const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
const location = useLocation();
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const startCourse = currentParams.get('start_course');
if (startCourse === '1') {
sendTrackEvent('welcome.email.clicked.startcourse', {});
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
history.replace({
search: currentParams.toString(),
});
}
}, [location.search]);
return ( 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 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 className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div> <div role="heading" aria-level="1" className="h2">{title}</div>
@@ -166,18 +159,29 @@ function OutlineTab({ intl }) {
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} /> <UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
</> </>
)} )}
{!deprecatedCourseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<CourseGoalCard
courseId={courseId}
goalOptions={goalOptions}
title={title}
setGoalToDisplay={(newGoal) => { setDeprecatedCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
{resumeCourseUrl && (
<StartOrResumeCourseCard /> <StartOrResumeCourseCard />
)}
<WelcomeMessage courseId={courseId} /> <WelcomeMessage courseId={courseId} />
{rootCourseId && ( {rootCourseId && (
<> <>
<div className="row w-100 m-0 mb-3 justify-content-end"> <div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-md-auto p-0"> <div className="col-12 col-sm-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}> <Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)} {expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button> </Button>
</div> </div>
</div> </div>
<ol id="courseHome-outline" className="list-unstyled"> <ol className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => ( {courses[rootCourseId].sectionIds.map((sectionId) => (
<Section <Section
key={sectionId} key={sectionId}
@@ -193,16 +197,29 @@ function OutlineTab({ intl }) {
</div> </div>
{rootCourseId && ( {rootCourseId && (
<div className="col col-12 col-md-4"> <div className="col col-12 col-md-4">
<ProctoringInfoPanel /> <ProctoringInfoPanel
{ /** Defer showing the goal widget until the ProctoringInfoPanel has resolved or has been determined as courseId={courseId}
disabled to avoid components bouncing around too much as screen is rendered */ } username={username}
{(!enableProctoredExams || proctoringPanelStatus === 'loaded') && weeklyLearningGoalEnabled && ( />
{deprecatedCourseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
selectedGoal={deprecatedCourseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setDeprecatedCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
{weeklyLearningGoalEnabled && (
<WeeklyLearningGoalCard <WeeklyLearningGoalCard
daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null} daysPerWeek={selectedGoal && 'daysPerWeek' in selectedGoal ? selectedGoal.daysPerWeek : null}
subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false} subscribedToReminders={selectedGoal && 'subscribedToReminders' in selectedGoal ? selectedGoal.subscribedToReminders : false}
courseId={courseId}
/> />
)} )}
<CourseTools /> <CourseTools
courseId={courseId}
/>
{ /** [MM-P2P] Experiment (conditional) */ } { /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled { MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} /> ? <MMP2PFlyover isStatic options={MMP2P} />
@@ -212,7 +229,6 @@ function OutlineTab({ intl }) {
verifiedMode={verifiedMode} verifiedMode={verifiedMode}
accessExpiration={accessExpiration} accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled} contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home" upsellPageName="course_home"
userTimezone={userTimezone} userTimezone={userTimezone}
shouldDisplayBorder shouldDisplayBorder
@@ -222,10 +238,13 @@ function OutlineTab({ intl }) {
/> />
)} )}
<CourseDates <CourseDates
courseId={courseId}
/** [MM-P2P] Experiment */ /** [MM-P2P] Experiment */
mmp2p={MMP2P} mmp2p={MMP2P}
/> />
<CourseHandouts /> <CourseHandouts
courseId={courseId}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,8 +1,4 @@
/**
* @jest-environment jsdom
*/
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
@@ -29,21 +25,21 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => { describe('Outline Tab', () => {
let axiosMock; let axiosMock;
const courseId = 'course-v1:edX+DemoX+Demo_Course'; const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`; const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?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 store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata'); const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('outlineTabData'); const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) { function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options); const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
} }
@@ -52,14 +48,9 @@ describe('Outline Tab', () => {
axiosMock.onGet(outlineUrl).reply(200, outlineTabData); axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
} }
async function fetchAndRender(path = '') { async function fetchAndRender() {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render( await act(async () => render(<OutlineTab />, { store }));
<MemoryRouter initialEntries={[path]}>
<OutlineTab />
</MemoryRouter>,
{ store },
));
} }
beforeEach(async () => { beforeEach(async () => {
@@ -83,7 +74,7 @@ describe('Outline Tab', () => {
describe('Course Outline', () => { describe('Course Outline', () => {
it('displays link to start course', async () => { it('displays link to start course', async () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
}); });
it('displays link to resume course', async () => { it('displays link to resume course', async () => {
@@ -144,8 +135,25 @@ describe('Outline Tab', () => {
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument(); expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
}); });
it('SequenceLink displays link', async () => { it('SequenceLink displays points to legacy courseware', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true }); const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: false,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
});
it('SequenceLink displays points to courseware MFE', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: true,
});
setTabData({ setTabData({
course_blocks: { blocks: courseBlocks.blocks }, course_blocks: { blocks: courseBlocks.blocks },
}); });
@@ -321,47 +329,95 @@ describe('Outline Tab', () => {
}); });
}); });
describe('Start or Resume Course Card', () => { describe('Course Goals', () => {
it('renders startOrResumeCourseCard', async () => { const goalOptions = [
['certify', 'Earn a certificate'],
['complete', 'Complete the course'],
['explore', 'Explore the course'],
['unsure', 'Not sure yet'],
];
it('does not render goal widgets if no goals available', async () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument(); expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
});
describe('goal is not set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: null,
},
});
await fetchAndRender();
});
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('renders goal selector on goal selection', async () => {
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
fireEvent.click(certifyGoalButton);
const goalSelector = await screen.findByTestId('edit-goal-selector');
expect(goalSelector).toBeInTheDocument();
});
});
describe('goal is set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: { text: 'Earn a certificate', key: 'certify' },
},
});
await fetchAndRender();
});
it('renders edit goal selector', () => {
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
});
it('updates goal on click', async () => {
// Open dropdown
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
await waitFor(() => {
expect(dropdownButtonNode).toBeInTheDocument();
});
fireEvent.click(dropdownButtonNode);
// Select a new goal
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
await waitFor(() => {
expect(unsureButtonNode).toBeInTheDocument();
});
fireEvent.click(unsureButtonNode);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
});
}); });
}); });
describe('Weekly Learning Goal', () => { describe('Weekly Learning Goals', () => {
it('does not post goals while masquerading', async () => { it('does not render weekly learning goal if weeklyLearningGoalEnabled is false', 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(); await fetchAndRender();
const button = await screen.getByTestId('weekly-learning-goal-input-Regular'); expect(screen.queryByTestId('weekly-learning-goal-card')).not.toBeInTheDocument();
fireEvent.click(button);
expect(spy).toHaveBeenCalledTimes(0);
});
it('post goal via query param', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?weekly_goal=3');
expect(spy).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.setgoal', {});
});
it('emit start course event via query param', async () => {
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?start_course=1');
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.startcourse', {});
}); });
describe('weekly learning goal is not set', () => { describe('weekly learning goal is not set', () => {
@@ -371,7 +427,6 @@ describe('Outline Tab', () => {
weekly_learning_goal_enabled: true, weekly_learning_goal_enabled: true,
}, },
}); });
await fetchAndRender(); await fetchAndRender();
}); });
@@ -379,31 +434,20 @@ describe('Outline Tab', () => {
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument(); expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
}); });
it('renders startOrResumeCourseCard', async () => {
expect(screen.queryByTestId('start-resume-card')).toBeInTheDocument();
});
it('disables the subscribe button if no goal is set', async () => { it('disables the subscribe button if no goal is set', async () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled(); expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
}); });
it.each` it('calls the API when a button is clicked', async () => {
level | days expect(screen.queryByText(messages.casualGoalButtonText.defaultMessage)).toBeInTheDocument();
${'Casual'} | ${1} expect(screen.getByText(messages.casualGoalButtonText.defaultMessage).closest('button')).toBeInTheDocument();
${'Regular'} | ${3}
${'Intense'} | ${5} // click on Casual goal
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => { const button = await screen.getByText(messages.casualGoalButtonText.defaultMessage).closest('button');
// 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); fireEvent.click(button);
// Verify the request was made // Verify the request was made
await waitFor(() => { await waitFor(() => {
@@ -415,7 +459,7 @@ describe('Outline Tab', () => {
}); });
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled(); expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
// Click on subscribe to reminders toggle // Click on subscribe to reminders
const subscriptionSwitch = await screen.getByRole('switch', { name: messages.setGoalReminder.defaultMessage }); const subscriptionSwitch = await screen.getByRole('switch', { name: messages.setGoalReminder.defaultMessage });
expect(subscriptionSwitch).toBeInTheDocument(); expect(subscriptionSwitch).toBeInTheDocument();
@@ -430,56 +474,6 @@ describe('Outline Tab', () => {
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).not.toBeInTheDocument(); expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).not.toBeInTheDocument();
}); });
}); });
it('has button for weekly learning goal selected', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
selected_goal: {
subscribed_to_reminders: true,
days_per_week: 3,
},
},
});
await fetchAndRender();
const button = await screen.queryByTestId('weekly-learning-goal-input-Regular');
expect(button).toBeInTheDocument();
expect(button).toHaveClass('flag-button-selected');
});
it('renders weekly learning goal card if ProctoringInfoPanel is not shown', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is not enabled', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
enableProctoredExams: false,
},
});
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
it('renders weekly learning goal card if ProctoringInfoPanel is enabled', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
enableProctoredExams: true,
},
});
await fetchAndRender();
expect(screen.queryByTestId('weekly-learning-goal-card')).toBeInTheDocument();
});
}); });
describe('Course Handouts', () => { describe('Course Handouts', () => {
@@ -577,7 +571,7 @@ describe('Outline Tab', () => {
const instructorToolbar = await screen.getByTestId('instructor-toolbar'); const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument(); 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('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 () => { it('does not render banner when not masquerading', async () => {
@@ -668,6 +662,7 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE, cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: tomorrow.toISOString(), certificate_available_date: tomorrow.toISOString(),
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -684,7 +679,7 @@ describe('Outline Tab', () => {
], ],
}); });
await fetchAndRender(); 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 () => { it('renders verification alert', async () => {
const now = new Date(); const now = new Date();
@@ -695,6 +690,7 @@ describe('Outline Tab', () => {
cert_data: { cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED, cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null, cert_web_view_url: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -717,7 +713,7 @@ describe('Outline Tab', () => {
], ],
}); });
await fetchAndRender(); 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 () => { it('renders non passing grade', async () => {
const now = new Date(); const now = new Date();
@@ -750,8 +746,8 @@ describe('Outline Tab', () => {
], ],
}); });
await fetchAndRender(); await fetchAndRender();
screen.getAllByText('You are not yet eligible for a certificate'); screen.getAllByText('You are not eligible for a certificate');
expect(screen.queryByText('You are not yet eligible for a certificate')).toBeInTheDocument(); expect(screen.queryByText('You are not eligible for a certificate')).toBeInTheDocument();
}); });
it('tracks request cert button', async () => { it('tracks request cert button', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
@@ -763,6 +759,7 @@ describe('Outline Tab', () => {
cert_data: { cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING, cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null, cert_web_view_url: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -791,12 +788,55 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1); 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, is_staff: false,
org_key: 'edX', org_key: 'edX',
}); });
}); });
it('tracks unverified cert button', async () => { it('tracks unverified cert button', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
const now = new Date(); const now = new Date();
@@ -807,6 +847,7 @@ describe('Outline Tab', () => {
cert_data: { cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED, cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null, cert_web_view_url: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -835,7 +876,7 @@ describe('Outline Tab', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(1); 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, is_staff: false,
org_key: 'edX', org_key: 'edX',
}); });
@@ -878,7 +919,7 @@ describe('Outline Tab', () => {
], ],
}); });
await fetchAndRender(); 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(); expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
}); });
}); });
@@ -894,6 +935,7 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE, cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: 'certificate/testuuid', cert_web_view_url: 'certificate/testuuid',
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -919,6 +961,7 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.REQUESTING, cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
}, { }, {
date_blocks: [ date_blocks: [
@@ -935,6 +978,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', () => { describe('Proctoring Info Panel', () => {
const onboardingReleaseDate = new Date(); const onboardingReleaseDate = new Date();
onboardingReleaseDate.setDate(new Date().getDate() - 7); onboardingReleaseDate.setDate(new Date().getDate() - 7);
@@ -1017,22 +1087,6 @@ describe('Outline Tab', () => {
}); });
it('displays expiration warning', async () => { it('displays expiration warning', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expiration warning for other course', async () => {
const expirationDate = new Date(); const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future // This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000); expirationDate.setTime(expirationDate.getTime() + 864800000);
@@ -1044,23 +1098,7 @@ describe('Outline Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
await screen.findByText('This course contains proctored exams'); await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument(); expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expired', async () => {
const expirationDate = new Date();
// This message appears after expiration, set the date 10 days in the past
expirationDate.setTime(expirationDate.getTime() - 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument(); expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
}); });
@@ -1081,7 +1119,6 @@ describe('Outline Tab', () => {
it('does not appear for 404', async () => { it('does not appear for 404', async () => {
axiosMock.onGet(proctoringInfoUrl).reply(404); axiosMock.onGet(proctoringInfoUrl).reply(404);
await fetchAndRender();
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
}); });
@@ -1195,7 +1232,7 @@ describe('Outline Tab', () => {
}); });
}); });
describe('Account Activation Alert', () => { describe('Accont Activation Alert', () => {
beforeEach(() => { beforeEach(() => {
const intersectionObserverMock = () => ({ const intersectionObserverMock = () => ({
observe: () => null, observe: () => null,
@@ -1223,7 +1260,7 @@ describe('Outline Tab', () => {
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
}); });
it('sends account activation email on clicking the re-send email in account activation alert', async () => { it('sends account activation email on clicking the resened email in account activation alert', async () => {
Cookies.set = jest.fn(); Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true'); Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); }); Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Hyperlink } from '@edx/paragon';
import { import {
FormattedMessage, FormattedMessage,
FormattedTime, FormattedTime,
@@ -27,16 +28,25 @@ function SequenceLink({
complete, complete,
description, description,
due, due,
legacyWebUrl,
showLink, showLink,
title, title,
} = sequence; } = sequence;
const { const {
userTimezone, userTimezone,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const {
canLoadCourseware,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; 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 displayTitle = showLink ? coursewareUrl : title;
return ( return (

View File

@@ -33,6 +33,7 @@ function CertificateStatusAlert({ intl, payload }) {
courseEndDate, courseEndDate,
courseId, courseId,
certURL, certURL,
isWebCert,
userTimezone, userTimezone,
org, org,
notPassingCourseEnded, notPassingCourseEnded,
@@ -65,8 +66,8 @@ function CertificateStatusAlert({ intl, payload }) {
alertProps.body = ( alertProps.body = (
<p> <p>
<FormattedMessage <FormattedMessage
id="learning.outline.alert.cert.earnedNotAvailable" id="learning.outline.alert.cert.when"
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
scheduled to be available after {certificateAvailableDate}." scheduled to be available after {certificateAvailableDate}."
values={{ values={{
courseEndDateFormatted, courseEndDateFormatted,
@@ -78,7 +79,11 @@ function CertificateStatusAlert({ intl, payload }) {
); );
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) { } else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader); 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.buttonVisible = true;
alertProps.buttonLink = certURL; alertProps.buttonLink = certURL;
alertProps.buttonAction = () => { alertProps.buttonAction = () => {
@@ -199,6 +204,7 @@ CertificateStatusAlert.propTypes = {
courseEndDate: PropTypes.string, courseEndDate: PropTypes.string,
courseId: PropTypes.string, courseId: PropTypes.string,
certURL: PropTypes.string, certURL: PropTypes.string,
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string, userTimezone: PropTypes.string,
org: PropTypes.string, org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool, notPassingCourseEnded: PropTypes.bool,

View File

@@ -51,8 +51,10 @@ function useCertificateStatusAlert(courseId) {
certStatus, certStatus,
certWebViewUrl, certWebViewUrl,
certificateAvailableDate, certificateAvailableDate,
downloadUrl,
} = certData || {}; } = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date'); const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = ( const isVerifiedEnrollmentMode = (
enrollmentMode !== null enrollmentMode !== null
&& enrollmentMode !== undefined && enrollmentMode !== undefined
@@ -61,6 +63,9 @@ function useCertificateStatusAlert(courseId) {
let certURL = ''; let certURL = '';
if (certWebViewUrl) { if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
} else if (downloadUrl) {
// PDF Certificate
certURL = downloadUrl;
} }
const hasAlertingCertStatus = verifyCertStatusType(certStatus); const hasAlertingCertStatus = verifyCertStatusType(certStatus);
@@ -82,6 +87,7 @@ function useCertificateStatusAlert(courseId) {
courseId, courseId,
courseEndDate: endBlock && endBlock.date, courseEndDate: endBlock && endBlock.date,
userTimezone, userTimezone,
isWebCert,
org, org,
notPassingCourseEnded, notPassingCourseEnded,
tabs, tabs,

View File

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

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { import {
FormattedDate, FormattedDate,
FormattedMessage, FormattedMessage,
FormattedRelativeTime, FormattedRelative,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
@@ -21,7 +21,7 @@ function CourseEndAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const timeRemaining = ( const timeRemaining = (
<FormattedRelativeTime <FormattedRelative
key="timeRemaining" key="timeRemaining"
value={endDate} value={endDate}
{...timezoneFormatArgs} {...timezoneFormatArgs}

View File

@@ -4,22 +4,6 @@ const messages = defineMessages({
allDates: { allDates: {
id: 'learning.outline.dates.all', id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates', 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: { collapseAll: {
id: 'learning.outline.collapseAll', id: 'learning.outline.collapseAll',
@@ -39,7 +23,6 @@ const messages = defineMessages({
dates: { dates: {
id: 'learning.outline.dates', id: 'learning.outline.dates',
defaultMessage: 'Important dates', defaultMessage: 'Important dates',
description: 'Headline for the (summary of dates) section of the outline page',
}, },
editGoal: { editGoal: {
id: 'learning.outline.editGoal', id: 'learning.outline.editGoal',
@@ -56,11 +39,6 @@ const messages = defineMessages({
defaultMessage: 'Goal', defaultMessage: 'Goal',
description: 'Label for the selected course goal', description: 'Label for the selected course goal',
}, },
goalReminderDetail: {
id: 'learning.outline.goalReminderDetail',
defaultMessage: 'If we notice youre not quite at your goal, well send you an email reminder.',
description: 'It describe to learner what is goal reminder service',
},
goalUnsure: { goalUnsure: {
id: 'learning.outline.goalUnsure', id: 'learning.outline.goalUnsure',
defaultMessage: 'Not sure yet', defaultMessage: 'Not sure yet',
@@ -68,7 +46,6 @@ const messages = defineMessages({
handouts: { handouts: {
id: 'learning.outline.handouts', id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts', defaultMessage: 'Course Handouts',
description: 'Header for (Course Handouts) section in course outline',
}, },
incompleteAssignment: { incompleteAssignment: {
id: 'learning.outline.incompleteAssignment', id: 'learning.outline.incompleteAssignment',
@@ -80,16 +57,6 @@ const messages = defineMessages({
defaultMessage: 'Incomplete section', defaultMessage: 'Incomplete section',
description: 'Text used to describe the gray checkmark icon in front of a section title', 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: { learnMore: {
id: 'learning.outline.learnMore', id: 'learning.outline.learnMore',
defaultMessage: 'Learn More', defaultMessage: 'Learn More',
@@ -99,21 +66,9 @@ const messages = defineMessages({
defaultMessage: 'Open', defaultMessage: 'Open',
description: 'A button to open the given section of the course outline', description: 'A button to open the given section of the course outline',
}, },
proctoringInfoPanel: { startBlurb: {
id: 'learning.proctoringPanel.header', id: 'learning.outline.startBlurb',
defaultMessage: 'This course contains proctored exams', defaultMessage: 'Begin your course today',
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: { resumeBlurb: {
id: 'learning.outline.resumeBlurb', id: 'learning.outline.resumeBlurb',
@@ -123,56 +78,39 @@ const messages = defineMessages({
resume: { resume: {
id: 'learning.outline.resume', id: 'learning.outline.resume',
defaultMessage: 'Resume course', defaultMessage: 'Resume course',
description: 'Anchor text for button that would resume course',
}, },
setGoal: { setGoal: {
id: 'learning.outline.setGoal', id: 'learning.outline.setGoal',
defaultMessage: 'To start, set a course goal by selecting the option below that best describes your learning plan.', 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: { setWeeklyGoal: {
id: 'learning.outline.setWeeklyGoal', id: 'learning.outline.setWeeklyGoal',
defaultMessage: 'Set a weekly learning goal', defaultMessage: 'Set a weekly learning goal',
description: 'The headline for (goal reminder service) section in course outline',
}, },
setWeeklyGoalDetail: { setWeeklyGoalDetail: {
id: 'learning.outline.setWeeklyGoalDetail', id: 'learning.outline.setWeeklyGoalDetail',
defaultMessage: 'Setting a goal motivates you to finish the course. You can always change it later.', 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: { start: {
id: 'learning.outline.start', id: 'learning.outline.start',
defaultMessage: 'Start course', defaultMessage: 'Start Course',
description: 'The text for button which starts the course',
},
startBlurb: {
id: 'learning.outline.startBlurb',
defaultMessage: 'Begin your course today',
}, },
tools: { tools: {
id: 'learning.outline.tools', id: 'learning.outline.tools',
defaultMessage: 'Course 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: { upgradeButton: {
id: 'learning.outline.upgradeButton', id: 'learning.outline.upgradeButton',
defaultMessage: 'Upgrade ({symbol}{price})', defaultMessage: 'Upgrade ({symbol}{price})',
description: 'Text for the button which redirects to the upgrading page',
}, },
upgradeTitle: { upgradeTitle: {
id: 'learning.outline.upgradeTitle', id: 'learning.outline.upgradeTitle',
defaultMessage: 'Pursue a verified certificate', 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: { welcomeMessage: {
id: 'learning.outline.welcomeMessage', id: 'learning.outline.welcomeMessage',
@@ -191,145 +129,158 @@ const messages = defineMessages({
defaultMessage: 'Welcome to', defaultMessage: 'Welcome to',
description: 'This precedes the title of the course', description: 'This precedes the title of the course',
}, },
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',
},
casualGoalButtonTitle: {
id: 'learning.outline.goalButton.screenReader.text',
defaultMessage: 'Casual',
description: 'A very short description of the least intense of three learning goals',
},
casualGoalButtonText: {
id: 'learning.outline.goalButton.casual.text',
defaultMessage: '1 day a week',
},
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',
},
regularGoalButtonText: {
id: 'learning.outline.goalButton.regular.text',
defaultMessage: '3 days a week',
},
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',
},
intenseGoalButtonText: {
id: 'learning.outline.goalButton.intense.text',
defaultMessage: '5 days a week',
},
setGoalReminder: {
id: 'learning.outline.setGoalReminder',
defaultMessage: 'Set a goal reminder',
},
goalReminderDetail: {
id: 'learning.outline.goalReminderDetail',
defaultMessage: 'If we notice youre not quite at your goal, well send you an email reminder.',
},
proctoringInfoPanel: {
id: 'learning.proctoringPanel.header',
defaultMessage: 'This course contains proctored exams',
},
notStartedProctoringStatus: { notStartedProctoringStatus: {
id: 'learning.proctoringPanel.status.notStarted', id: 'learning.proctoringPanel.status.notStarted',
defaultMessage: 'Not Started', defaultMessage: 'Not Started',
description: 'It indcate that proctortrack onboarding exam hasnt started yet',
}, },
startedProctoringStatus: { startedProctoringStatus: {
id: 'learning.proctoringPanel.status.started', id: 'learning.proctoringPanel.status.started',
defaultMessage: 'Started', defaultMessage: 'Started',
description: 'Label to indicate the starting status of the proctortrack onboarding exam',
}, },
submittedProctoringStatus: { submittedProctoringStatus: {
id: 'learning.proctoringPanel.status.submitted', id: 'learning.proctoringPanel.status.submitted',
defaultMessage: 'Submitted', defaultMessage: 'Submitted',
description: 'Label to indicate the submitted status of proctortrack onboarding exam',
}, },
verifiedProctoringStatus: { verifiedProctoringStatus: {
id: 'learning.proctoringPanel.status.verified', id: 'learning.proctoringPanel.status.verified',
defaultMessage: 'Verified', defaultMessage: 'Verified',
description: 'Label to indicate the verified status of the proctortrack onboarding exam',
}, },
rejectedProctoringStatus: { rejectedProctoringStatus: {
id: 'learning.proctoringPanel.status.rejected', id: 'learning.proctoringPanel.status.rejected',
defaultMessage: 'Rejected', defaultMessage: 'Rejected',
description: 'Label to indicate the rejection status of the proctortrack onboarding exam',
}, },
errorProctoringStatus: { errorProctoringStatus: {
id: 'learning.proctoringPanel.status.error', id: 'learning.proctoringPanel.status.error',
defaultMessage: 'Error', defaultMessage: 'Error',
description: 'Label to indicate that there is error in proctortrack onboarding exam',
}, },
otherCourseApprovedProctoringStatus: { otherCourseApprovedProctoringStatus: {
id: 'learning.proctoringPanel.status.otherCourseApproved', id: 'learning.proctoringPanel.status.otherCourseApproved',
defaultMessage: 'Approved in Another Course', 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: { expiringSoonProctoringStatus: {
id: 'learning.proctoringPanel.status.expiringSoon', id: 'learning.proctoringPanel.status.expiringSoon',
defaultMessage: 'Expiring Soon', 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: { proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status', id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:', defaultMessage: 'Current Onboarding Status:',
description: 'The text that precede the status label of proctortrack onboarding exam',
}, },
notStartedProctoringMessage: { notStartedProctoringMessage: {
id: 'learning.proctoringPanel.message.notStarted', id: 'learning.proctoringPanel.message.notStarted',
defaultMessage: 'You have not started your onboarding exam.', 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: { startedProctoringMessage: {
id: 'learning.proctoringPanel.message.started', id: 'learning.proctoringPanel.message.started',
defaultMessage: 'You have started your onboarding exam.', defaultMessage: 'You have started your onboarding exam.',
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam',
}, },
submittedProctoringMessage: { submittedProctoringMessage: {
id: 'learning.proctoringPanel.message.submitted', id: 'learning.proctoringPanel.message.submitted',
defaultMessage: 'You have submitted your onboarding exam.', defaultMessage: 'You have submitted your onboarding exam.',
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam',
}, },
verifiedProctoringMessage: { verifiedProctoringMessage: {
id: 'learning.proctoringPanel.message.verified', id: 'learning.proctoringPanel.message.verified',
defaultMessage: 'Your onboarding exam has been approved in this course.', 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: { rejectedProctoringMessage: {
id: 'learning.proctoringPanel.message.rejected', id: 'learning.proctoringPanel.message.rejected',
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.', 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: { errorProctoringMessage: {
id: 'learning.proctoringPanel.message.error', id: 'learning.proctoringPanel.message.error',
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.', 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: { otherCourseApprovedProctoringMessage: {
id: 'learning.proctoringPanel.message.otherCourseApproved', id: 'learning.proctoringPanel.message.otherCourseApproved',
defaultMessage: 'Your onboarding exam has been approved in another course.', 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: { otherCourseApprovedProctoringDetail: {
id: 'learning.proctoringPanel.detail.otherCourseApproved', 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.', 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: { expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon', 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.', defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
},
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)',
}, },
proctoringPanelGeneralInfo: { proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo', id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ', 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: { proctoringPanelGeneralInfoSubmitted: {
id: 'learning.proctoringPanel.generalInfoSubmitted', id: 'learning.proctoringPanel.generalInfoSubmitted',
defaultMessage: 'Your submitted profile is in review.', defaultMessage: 'Your submitted profile is in review.',
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam',
}, },
proctoringPanelGeneralTime: { proctoringPanelGeneralTime: {
id: 'learning.proctoringPanel.generalTime', id: 'learning.proctoringPanel.generalTime',
defaultMessage: 'Onboarding profile review can take 2+ business days.', 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: { proctoringOnboardingButton: {
id: 'learning.proctoringPanel.onboardingButton', id: 'learning.proctoringPanel.onboardingButton',
defaultMessage: 'Complete Onboarding', defaultMessage: 'Complete Onboarding',
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released',
}, },
proctoringOnboardingPracticeButton: { proctoringOnboardingPracticeButton: {
id: 'learning.proctoringPanel.onboardingPracticeButton', id: 'learning.proctoringPanel.onboardingPracticeButton',
defaultMessage: 'View Onboarding Exam', 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: { proctoringOnboardingButtonNotOpen: {
id: 'learning.proctoringPanel.onboardingButtonNotOpen', id: 'learning.proctoringPanel.onboardingButtonNotOpen',
defaultMessage: 'Onboarding Opens: {releaseDate}', defaultMessage: 'Onboarding Opens: {releaseDate}',
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam',
}, },
proctoringReviewRequirementsButton: { proctoringReviewRequirementsButton: {
id: 'learning.proctoringPanel.reviewRequirementsButton', id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements', 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: { proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue', id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due', 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', },
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',
}, },
}); });

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import PropTypes from 'prop-types';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
@@ -12,12 +12,8 @@ import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
import messages from '../messages'; import messages from '../messages';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
function CourseTools({ intl }) { function CourseTools({ courseId, intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const { org } = useModel('courseHomeMeta', courseId); const { org } = useModel('courseHomeMeta', courseId);
const { const {
courseTools, courseTools,
@@ -73,16 +69,18 @@ function CourseTools({ intl }) {
</a> </a>
</li> </li>
))} ))}
<li className="small" id="courseHome-launchTourLink">
<LaunchCourseHomeTourButton />
</li>
</ul> </ul>
</section> </section>
); );
} }
CourseTools.propTypes = { CourseTools.propTypes = {
courseId: PropTypes.string,
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };
CourseTools.defaultProps = {
courseId: null,
};
export default injectIntl(CourseTools); export default injectIntl(CourseTools);

View File

@@ -1,31 +1,34 @@
import React from 'react'; import React, { useState } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames';
function FlagButton({ function FlagButton({
buttonIcon, ButtonIcon,
srText,
title, title,
text, text,
isEnabled,
handleSelect, handleSelect,
isSelected,
}) { }) {
const [isSelected, setIsSelected] = useState(false);
return ( return (
<button <button
type="button" type="button"
className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5', className={classNames('col flex-grow-1 p-3 border border-light rounded bg-white', { 'border-dark': isEnabled || isSelected })}
isSelected ? 'flag-button-selected' : '')} onMouseEnter={() => setIsSelected(true)}
aria-checked={isSelected} onMouseLeave={() => setIsSelected(false)}
role="radio"
onClick={() => handleSelect()} onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
> >
<div className="row w-100 m-0 justify-content-center pb-1"> <div className=" justify-content-center">
{buttonIcon} {ButtonIcon}
</div> </div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}> <span className="sr-only sr-only-focusable">{srText}</span>
<div className="text-center small">
{title} {title}
</div> </div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}> <div className="text-center micro">
{text} {text}
</div> </div>
</button> </button>
@@ -33,11 +36,16 @@ function FlagButton({
} }
FlagButton.propTypes = { FlagButton.propTypes = {
buttonIcon: PropTypes.element.isRequired, ButtonIcon: PropTypes.element.isRequired,
srText: PropTypes.string.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string,
isEnabled: PropTypes.bool,
handleSelect: PropTypes.func.isRequired, handleSelect: PropTypes.func.isRequired,
isSelected: PropTypes.bool.isRequired, };
FlagButton.defaultProps = {
isEnabled: false,
text: '',
}; };
export default FlagButton; export default FlagButton;

View File

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

View File

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

View File

@@ -1,24 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import camelCase from 'lodash.camelcase'; import camelCase from 'lodash.camelcase';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import messages from '../messages'; import messages from '../messages';
import { getProctoringInfoData } from '../../data/api'; import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
function ProctoringInfoPanel({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
username,
} = useModel('courseHomeMeta', courseId);
const dispatch = useDispatch();
function ProctoringInfoPanel({ courseId, username, intl }) {
const [link, setLink] = useState(''); const [link, setLink] = useState('');
const [onboardingPastDue, setOnboardingPastDue] = useState(false); const [onboardingPastDue, setOnboardingPastDue] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false); const [showInfoPanel, setShowInfoPanel] = useState(false);
@@ -35,7 +25,6 @@ function ProctoringInfoPanel({ intl }) {
error: 'error', error: 'error',
otherCourseApproved: 'otherCourseApproved', otherCourseApproved: 'otherCourseApproved',
expiringSoon: 'expiringSoon', expiringSoon: 'expiringSoon',
expired: 'expired',
}; };
function getReadableStatusClass(examStatus) { function getReadableStatusClass(examStatus) {
@@ -55,14 +44,9 @@ function ProctoringInfoPanel({ intl }) {
return readableClass; return readableClass;
} }
function isCurrentlySubmitted(examStatus) { function isNotYetSubmitted(examStatus) {
const SUBMITTED_STATES = ['submitted', 'second_review_required']; const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
return SUBMITTED_STATES.includes(examStatus); return !NO_SHOW_STATES.includes(examStatus);
}
function isSubmissionRequired(examStatus) {
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
return !OK_STATES.includes(examStatus);
} }
function isNotYetReleased(examReleaseDate) { function isNotYetReleased(examReleaseDate) {
@@ -83,19 +67,11 @@ function ProctoringInfoPanel({ intl }) {
return borderClass; 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) { function isExpiringSoon(dateString) {
// Returns true if the expiration date is within 28 days // Returns true if the expiration date is within 28 days
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
const today = new Date(); const today = new Date();
const expirationDateObject = new Date(dateString); const expirationDateObject = new Date(dateString);
return today > expirationDateObject.getTime() - twentyeightDays; return today > expirationDateObject.getTime() - 2419200000;
} }
useEffect(() => { useEffect(() => {
@@ -110,9 +86,7 @@ function ProctoringInfoPanel({ intl }) {
setStatus(response.onboarding_status); setStatus(response.onboarding_status);
setLink(response.onboarding_link); setLink(response.onboarding_link);
const expirationDate = response.expiration_date; const expirationDate = response.expiration_date;
if (expirationDate && isExpired(expirationDate)) { if (expirationDate && isExpiringSoon(expirationDate)) {
setReadableStatus(getReadableStatusClass('expired'));
} else if (expirationDate && isExpiringSoon(expirationDate)) {
setReadableStatus(getReadableStatusClass('expiringSoon')); setReadableStatus(getReadableStatusClass('expiringSoon'));
} else { } else {
setReadableStatus(getReadableStatusClass(response.onboarding_status)); setReadableStatus(getReadableStatusClass(response.onboarding_status));
@@ -121,13 +95,7 @@ function ProctoringInfoPanel({ intl }) {
setOnboardingPastDue(response.onboarding_past_due); setOnboardingPastDue(response.onboarding_past_due);
} }
}, },
) );
.catch(() => {
/* Do nothing. API throws 404 when class does not have proctoring */
})
.finally(() => {
dispatch(fetchProctoringInfoResolved());
});
}, []); }, []);
let onboardingExamButton = null; let onboardingExamButton = null;
@@ -191,17 +159,17 @@ function ProctoringInfoPanel({ intl }) {
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && ( {![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
<> <>
<p> <p>
{!isCurrentlySubmitted(status) && ( {isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfo) intl.formatMessage(messages.proctoringPanelGeneralInfo)
)} )}
{isCurrentlySubmitted(status) && ( {!isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted) intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
)} )}
</p> </p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p> <p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</> </>
)} )}
{isSubmissionRequired(readableStatus) && ( {isNotYetSubmitted(status) && (
onboardingExamButton onboardingExamButton
)} )}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams"> <Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
@@ -215,7 +183,13 @@ function ProctoringInfoPanel({ intl }) {
} }
ProctoringInfoPanel.propTypes = { ProctoringInfoPanel.propTypes = {
courseId: PropTypes.string.isRequired,
username: PropTypes.string,
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };
ProctoringInfoPanel.defaultProps = {
username: null,
};
export default injectIntl(ProctoringInfoPanel); export default injectIntl(ProctoringInfoPanel);

View File

@@ -26,11 +26,8 @@ function StartOrResumeCourseCard({ intl }) {
hasVisitedCourse, hasVisitedCourse,
url: resumeCourseUrl, url: resumeCourseUrl,
}, },
} = useModel('outline', courseId);
if (!resumeCourseUrl) { } = useModel('outline', courseId);
return null;
}
const logResumeCourseClick = () => { const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', { sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
@@ -41,22 +38,22 @@ function StartOrResumeCourseCard({ intl }) {
}; };
return ( return (
<Card className="mb-3 raised-card" data-testid="start-resume-card"> <Card className="mb-3" data-testid="start-resume-card">
<Card.Header <Card.Body>
title={hasVisitedCourse ? intl.formatMessage(messages.resumeBlurb) : intl.formatMessage(messages.startBlurb)} <div className="row w-100 m-0 ">
actions={( <h2 className="h4 col-auto flex-grow-1">{intl.formatMessage(messages.startBlurb)}</h2>
<Button <div className="col col-auto p-0 justify-content-end">
variant="brand" <Button
block variant="brand"
href={resumeCourseUrl} block
onClick={() => logResumeCourseClick()} href={resumeCourseUrl}
> onClick={() => logResumeCourseClick()}
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)} >
</Button> {hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
)} </Button>
/> </div>
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */} </div>
<Card.Footer><></></Card.Footer> </Card.Body>
</Card> </Card>
); );
} }

View File

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

View File

@@ -1,156 +1,168 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@edx/paragon'; 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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Email } from '@edx/paragon/icons'; import { Email } from '@edx/paragon/icons';
import { useSelector } from 'react-redux'; import { ReactComponent as FlagIntenseIcon } from '@edx/paragon/icons/svg/flag.svg';
import { ReactComponent as FlagCasualIcon } from './flag_outline.svg';
import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
import messages from '../messages'; import messages from '../messages';
import LearningGoalButton from './LearningGoalButton'; import FlagButton from './FlagButton';
import { saveWeeklyLearningGoal } from '../../data';
import { useModel } from '../../../generic/model-store'; import { saveWeeklyCourseGoal } from '../../data';
import './FlagButton.scss';
function WeeklyLearningGoalCard({ function WeeklyLearningGoalCard({
daysPerWeek, daysPerWeek,
subscribedToReminders, subscribedToReminders,
courseId,
intl, intl,
}) { }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
isMasquerading,
org,
} = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek); const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types // eslint-disable-next-line react/prop-types
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders); const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
const location = useLocation(); const weeklyLearningGoalLevels = {
CASUAL: 3,
REGULAR: 4,
INTENSE: 5,
};
Object.freeze(weeklyLearningGoalLevels);
function handleSelect(days, triggeredFromEmail = false) { function handleSelect(days) {
// Set the subscription button if this is the first time selecting a goal // Set the subscription button if this is the first time selecting a goal
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected; const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
setGetReminderSelected(selectReminders); setGetReminderSelected(selectReminders);
setDaysPerWeekGoal(days); setDaysPerWeekGoal(days);
if (!isMasquerading) { // don't save goal updates while masquerading saveWeeklyCourseGoal(courseId, days, selectReminders);
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('welcome.email.clicked.setgoal', {});
}
}
} }
function handleSubscribeToReminders(event) { function handleSubscribeToReminders(event) {
const isGetReminderChecked = event.target.checked; const isGetReminderChecked = event.target.checked;
setGetReminderSelected(isGetReminderChecked); setGetReminderSelected(isGetReminderChecked);
if (!isMasquerading) { // don't save goal updates while masquerading saveWeeklyCourseGoal(courseId, daysPerWeekGoal, isGetReminderChecked);
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(),
});
}
}, [location.search]);
return ( return (
<Card <div className="row w-100 m-0 p-0">
id="courseHome-weeklyLearningGoal" <Card className="mb-3" data-testid="weekly-learning-goal-card">
className="row w-100 m-0 mb-3 raised-card" <Card.Body>
data-testid="weekly-learning-goal-card" <Card.Title>
> <h4 className="m-0">{intl.formatMessage(messages.setWeeklyGoal)}</h4>
<Card.Header </Card.Title>
size="sm" <Card.Text>
title={(<div id="set-weekly-goal-header">{intl.formatMessage(messages.setWeeklyGoal)}</div>)} {intl.formatMessage(messages.setWeeklyGoalDetail)}
subtitle={intl.formatMessage(messages.setWeeklyGoalDetail)} </Card.Text>
/> <div
<Card.Section className="text-gray-700 small"> className="row w-100 m-0 p-0 justify-content-around"
<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> <label
</Form.Switch> htmlFor={weeklyLearningGoalLevels.CASUAL}
</div> className="col-auto col-md-12 col-xl-auto m-0 p-0 pb-md-3 pb-xl-0"
</Card.Section> >
{isGetReminderSelected && ( <input
<Card.Section muted> type="radio"
<div className="row w-100 m-0 small align-center"> id={weeklyLearningGoalLevels.CASUAL}
<div className="d-flex align-items-center pr-1"> name="learningGoal"
<Icon radioGroup="learningGoal"
className="text-primary-500" value={weeklyLearningGoalLevels.CASUAL}
src={Email} onChange={() => handleSelect(weeklyLearningGoalLevels.CASUAL)}
tabIndex="0"
checked={weeklyLearningGoalLevels.CASUAL === daysPerWeekGoal}
className="position-absolute invisible"
/> />
</div> <FlagButton
<div className="col"> ButtonIcon={<FlagCasualIcon />}
{intl.formatMessage(messages.goalReminderDetail)} srText={intl.formatMessage(messages.setLearningGoalButtonScreenReaderText)}
</div> title={intl.formatMessage(messages.casualGoalButtonTitle)}
text={intl.formatMessage(messages.casualGoalButtonText)}
isEnabled={weeklyLearningGoalLevels.CASUAL === daysPerWeekGoal}
handleSelect={() => { handleSelect(weeklyLearningGoalLevels.CASUAL); }}
/>
</label>
<label
htmlFor={weeklyLearningGoalLevels.REGULAR}
className="col-auto col-md-12 col-xl-auto m-0 p-0 pb-md-3 pb-xl-0"
>
<input
type="radio"
id={weeklyLearningGoalLevels.REGULAR}
name="learningGoal"
radioGroup="learningGoal"
value={weeklyLearningGoalLevels.REGULAR}
onChange={() => handleSelect(weeklyLearningGoalLevels.REGULAR)}
tabIndex="-1"
checked={weeklyLearningGoalLevels.REGULAR === daysPerWeekGoal}
className="position-absolute invisible"
/>
<FlagButton
ButtonIcon={<FlagRegularIcon />}
srText={intl.formatMessage(messages.setLearningGoalButtonScreenReaderText)}
title={intl.formatMessage(messages.regularGoalButtonTitle)}
text={intl.formatMessage(messages.regularGoalButtonText)}
isEnabled={weeklyLearningGoalLevels.REGULAR === daysPerWeekGoal}
handleSelect={() => { handleSelect(weeklyLearningGoalLevels.REGULAR); }}
/>
</label>
<label
htmlFor={weeklyLearningGoalLevels.INTENSE}
className="col-auto col-md-12 col-xl-auto m-0 p-0 pb-md-3 pb-xl-0"
>
<input
type="radio"
id={weeklyLearningGoalLevels.INTENSE}
name="learningGoal"
radioGroup="learningGoal"
value={weeklyLearningGoalLevels.INTENSE}
onChange={() => handleSelect(weeklyLearningGoalLevels.INTENSE)}
tabIndex="-1"
checked={weeklyLearningGoalLevels.INTENSE === daysPerWeekGoal}
className="position-absolute invisible"
/>
<FlagButton
ButtonIcon={<FlagIntenseIcon />}
srText={intl.formatMessage(messages.setLearningGoalButtonScreenReaderText)}
title={intl.formatMessage(messages.intenseGoalButtonTitle)}
text={intl.formatMessage(messages.intenseGoalButtonText)}
isEnabled={weeklyLearningGoalLevels.INTENSE === daysPerWeekGoal}
handleSelect={() => { handleSelect(weeklyLearningGoalLevels.INTENSE); }}
/>
</label>
</div> </div>
</Card.Section> <div className="row p-3">
)} <Form.Switch
</Card> checked={isGetReminderSelected}
onChange={(event) => handleSubscribeToReminders(event)}
disabled={!daysPerWeekGoal}
>
{intl.formatMessage(messages.setGoalReminder)}
</Form.Switch>
</div>
</Card.Body>
{isGetReminderSelected && (
<Card.Footer className="border-0 px-2.5">
<div className="row w-100 m-0 small align-center">
<div className="d-flex align-items-center pr-1.5">
<Icon src={Email} />
</div>
<div className="col align-center">
{intl.formatMessage(messages.goalReminderDetail)}
</div>
</div>
</Card.Footer>
)}
</Card>
</div>
); );
} }
WeeklyLearningGoalCard.propTypes = { WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number, daysPerWeek: PropTypes.number,
subscribedToReminders: PropTypes.bool, subscribedToReminders: PropTypes.bool,
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };

View File

@@ -37,7 +37,6 @@ function WelcomeMessage({ courseId, intl }) {
setDisplay(false); setDisplay(false);
dispatch(dismissWelcomeMessage(courseId)); dispatch(dismissWelcomeMessage(courseId));
}} }}
className="raised-card"
actions={messageCanBeShortened ? [ actions={messageCanBeShortened ? [
<Button <Button
onClick={() => setShowShortMessage(!showShortMessage)} onClick={() => setShowShortMessage(!showShortMessage)}

View File

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

Before

Width:  |  Height:  |  Size: 173 B

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { layoutGenerator } from 'react-break';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { breakpoints, useWindowSize } from '@edx/paragon';
import CertificateStatus from './certificate-status/CertificateStatus'; import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion'; import CourseCompletion from './course-completion/CourseCompletion';
@@ -23,15 +23,13 @@ function ProgressTab() {
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const windowWidth = useWindowSize().width; const layout = layoutGenerator({
if (windowWidth === undefined) { mobile: 0,
// Bail because we don't want to load <CertificateStatus/> twice, emitting 'visited' events both times. desktop: 992,
// 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 wideScreen = windowWidth >= breakpoints.large.minWidth; const OnMobile = layout.is('mobile');
const OnDesktop = layout.isAtLeast('desktop');
return ( return (
<> <>
<ProgressHeader /> <ProgressHeader />
@@ -39,9 +37,11 @@ function ProgressTab() {
{/* Main body */} {/* Main body */}
<div className="col-12 col-md-8 p-0"> <div className="col-12 col-md-8 p-0">
<CourseCompletion /> <CourseCompletion />
{!wideScreen && <CertificateStatus />} <OnMobile>
<CertificateStatus />
</OnMobile>
<CourseGrade /> <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 /> <GradeSummary />
<DetailedGrades /> <DetailedGrades />
</div> </div>
@@ -49,7 +49,9 @@ function ProgressTab() {
{/* Side panel */} {/* Side panel */}
<div className="col-12 col-md-4 p-0 px-md-4"> <div className="col-12 col-md-4 p-0 px-md-4">
{wideScreen && <CertificateStatus />} <OnDesktop>
<CertificateStatus />
</OnDesktop>
<RelatedLinks /> <RelatedLinks />
</div> </div>
</div> </div>

View File

@@ -3,7 +3,6 @@ import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@edx/paragon';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { import {
@@ -14,7 +13,6 @@ import * as thunks from '../data/thunks';
import initializeStore from '../../store'; import initializeStore from '../../store';
import ProgressTab from './ProgressTab'; import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage'; import LoadedTabPage from '../../tab-page/LoadedTabPage';
import messages from './grades/messages';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
@@ -22,21 +20,18 @@ jest.mock('@edx/frontend-platform/analytics');
describe('Progress Tab', () => { describe('Progress Tab', () => {
let axiosMock; let axiosMock;
const store = initializeStore(); const courseId = 'course-v1:edX+Test+run';
const defaultMetadata = Factory.build('courseHomeMetadata');
const defaultTabData = Factory.build('progressTabData');
const courseId = defaultMetadata.id;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`); const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; 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 store = initializeStore();
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2); const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('progressTabData');
function setMetadata(attributes, options) { function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options); const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
} }
@@ -68,7 +63,6 @@ describe('Progress Tab', () => {
it('sends event on click of dates tab link', async () => { it('sends event on click of dates tab link', async () => {
await fetchAndRender(); await fetchAndRender();
sendTrackEvent.mockClear();
const datesTabLink = screen.getByRole('link', { name: 'Dates' }); const datesTabLink = screen.getByRole('link', { name: 'Dates' });
fireEvent.click(datesTabLink); fireEvent.click(datesTabLink);
@@ -84,7 +78,6 @@ describe('Progress Tab', () => {
it('sends event on click of outline tab link', async () => { it('sends event on click of outline tab link', async () => {
await fetchAndRender(); await fetchAndRender();
sendTrackEvent.mockClear();
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' }); const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
@@ -136,8 +129,6 @@ describe('Progress Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument(); 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(); 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(); await fetchAndRender();
expect(screen.getByRole('button', { name: 'Grade range tooltip' })); 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(); 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(); await fetchAndRender();
expect(screen.getByRole('button', { name: 'Grade range tooltip' })); expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('80%');
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('80%');
expect(await screen.findByText('Youre currently passing this course with a grade of B (80-90%)')).toBeInTheDocument(); expect(await screen.findByText('Youre 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 () => { it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
sendTrackEvent.mockClear();
setTabData({ setTabData({
completion_summary: { completion_summary: {
complete_count: 1, complete_count: 1,
@@ -334,7 +322,6 @@ describe('Progress Tab', () => {
], ],
}); });
await fetchAndRender(); await fetchAndRender();
sendTrackEvent.mockClear();
expect(screen.getByText('locked feature')).toBeInTheDocument(); expect(screen.getByText('locked feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).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); expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
}); });
it('does not render subsections for which showGrades is false', async () => { it('renders correct current grade tooltip when showGrades is false', async () => {
// The second assignment has showGrades set to false, so it should not be shown. // 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({ setTabData({
section_scores: [ section_scores: [
{ {
@@ -494,31 +482,10 @@ describe('Progress Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.getByText('First subsection')).toBeInTheDocument(); expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument(); // 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();
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();
}); });
}); });
@@ -667,7 +634,9 @@ describe('Progress Tab', () => {
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument(); 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({ setTabData({
section_scores: [ section_scores: [
{ {
@@ -678,16 +647,12 @@ describe('Progress Tab', () => {
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection', display_name: 'First subsection',
has_graded_assignment: true, has_graded_assignment: true,
learner_has_access: true,
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 2, num_points_possible: 2,
percent_graded: 1.0, percent_graded: 1.0,
problem_scores: [{
earned: 1,
possible: 2,
}],
show_correctness: 'always', show_correctness: 'always',
show_grades: true, show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
}, },
], ],
}, },
@@ -695,24 +660,15 @@ describe('Progress Tab', () => {
display_name: 'Second section', display_name: 'Second section',
subsections: [ subsections: [
{ {
assignment_type: 'Exam', assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@98765',
display_name: 'Second subsection', display_name: 'Second subsection',
learner_has_access: true, learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 0, num_points_earned: 1,
num_points_possible: 1, num_points_possible: 1,
override: {
system: 'PROCTORING',
reason: 'Suspicious activity',
},
percent_graded: 1.0, percent_graded: 1.0,
problem_scores: [{
earned: 0,
possible: 1,
}],
show_correctness: 'always', show_correctness: 'always',
show_grades: true, show_grades: false,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection', url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
}, },
], ],
@@ -721,14 +677,7 @@ describe('Progress Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
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();
}); });
}); });
@@ -742,8 +691,8 @@ describe('Progress Tab', () => {
}); });
it('sends event on click of subsection link', async () => { it('sends event on click of subsection link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument(); expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const subsectionLink = screen.getByRole('link', { name: 'First subsection' }); 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 () => { it('sends event on click of course outline link', async () => {
await fetchAndRender();
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument(); expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0]; const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
@@ -800,7 +749,22 @@ describe('Progress Tab', () => {
describe('Certificate Status', () => { describe('Certificate Status', () => {
beforeAll(() => { 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', () => { 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 () => { it('Displays webview link', async () => {
setTabData({ setTabData({
certificate_data: { certificate_data: {
@@ -1180,114 +1187,6 @@ describe('Progress Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument(); 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', () => { describe('Access expiration masquerade banner', () => {
@@ -1303,7 +1202,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); 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('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 () => { it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true }); setMetadata({ is_enrolled: true, original_user_is_staff: true });
@@ -1316,7 +1215,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch); await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); 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('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 })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); 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('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 () => { it('does not render banner when not masquerading', async () => {
setMetadata({ setMetadata({
@@ -1344,7 +1243,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch); await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); 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('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();
}); });
}); });

View File

@@ -22,8 +22,6 @@ function CertificateStatus({ intl }) {
const { const {
isEnrolled, isEnrolled,
org, org,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
@@ -47,8 +45,6 @@ function CertificateStatus({ intl }) {
hasScheduledContent, hasScheduledContent,
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
); );
const eventProperties = { const eventProperties = {
@@ -61,11 +57,12 @@ function CertificateStatus({ intl }) {
let certStatus; let certStatus;
let certWebViewUrl; let certWebViewUrl;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; let downloadUrl;
if (certificateData) { if (certificateData) {
certStatus = certificateData.certStatus; certStatus = certificateData.certStatus;
certWebViewUrl = certificateData.certWebViewUrl; certWebViewUrl = certificateData.certWebViewUrl;
downloadUrl = certificateData.downloadUrl;
} }
let certCase; let certCase;
@@ -118,7 +115,6 @@ function CertificateStatus({ intl }) {
<FormattedMessage <FormattedMessage
id="progress.certificateStatus.unverifiedBody" id="progress.certificateStatus.unverifiedBody"
defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}." defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}."
description="Its shown when learner are not verified thus it recommends going over the verification process"
values={{ idVerificationSupportLink }} values={{ idVerificationSupportLink }}
/> />
); );
@@ -137,14 +133,18 @@ function CertificateStatus({ intl }) {
Showcase your accomplishment on LinkedIn or your resumé today. Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}." {dashboardLink} and {profileLink}."
description="Recommending an action for learner when course certificate is available"
values={{ dashboardLink, profileLink }} values={{ dashboardLink, profileLink }}
/> />
); );
if (certWebViewUrl) { if (certWebViewUrl) {
certEventName = 'earned_viewable'; certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton); buttonText = intl.formatMessage(messages.viewableButton);
} else if (downloadUrl) {
certEventName = 'earned_downloadable';
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadableButton);
} }
break; break;
@@ -155,9 +155,8 @@ function CertificateStatus({ intl }) {
body = ( body = (
<FormattedMessage <FormattedMessage
id="courseCelebration.certificateBody.notAvailable.endDate" id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are defaultMessage="This course ends on {endDate}. Final grades and certificates are
scheduled to be available after {certAvailabilityDate}." scheduled to be available after {certAvailabilityDate}."
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
values={{ endDate, certAvailabilityDate }} values={{ endDate, certAvailabilityDate }}
/> />
); );
@@ -176,22 +175,10 @@ function CertificateStatus({ intl }) {
} }
break; break;
// This code shouldn't be hit but coding defensively since switch expects a default statement
default: default:
// if user completes a course before certificates are available, treat it as notAvailable certCase = null;
// regardless of passing or nonpassing status certEventName = 'no_certificate_status';
if (!canViewCertificate) {
certCase = 'notAvailable';
endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
} else {
certCase = null;
certEventName = 'no_certificate_status';
}
break; break;
} }
} }
@@ -234,12 +221,14 @@ function CertificateStatus({ intl }) {
return ( return (
<section data-testid="certificate-status-component" className="text-dark-700 mb-4"> <section data-testid="certificate-status-component" className="text-dark-700 mb-4">
<Card className="bg-light-200 raised-card"> <Card className="bg-light-200 shadow-sm border-0">
<Card.Header title={header} /> <Card.Body>
<Card.Section className="small text-gray-700"> <Card.Title>
{body} <h3>{header}</h3>
</Card.Section> </Card.Title>
<Card.Footer> <Card.Text className="small text-gray-700">
{body}
</Card.Text>
{buttonText && (buttonLocation || buttonAction) && ( {buttonText && (buttonLocation || buttonAction) && (
<Button <Button
variant="outline-brand" variant="outline-brand"
@@ -253,7 +242,7 @@ function CertificateStatus({ intl }) {
{buttonText} {buttonText}
</Button> </Button>
)} )}
</Card.Footer> </Card.Body>
</Card> </Card>
</section> </section>
); );

View File

@@ -4,107 +4,86 @@ const messages = defineMessages({
notPassingHeader: { notPassingHeader: {
id: 'progress.certificateStatus.notPassingHeader', id: 'progress.certificateStatus.notPassingHeader',
defaultMessage: 'Certificate status', defaultMessage: 'Certificate status',
description: 'Header text when learner certifcate status is not passing',
}, },
notPassingBody: { notPassingBody: {
id: 'progress.certificateStatus.notPassingBody', id: 'progress.certificateStatus.notPassingBody',
defaultMessage: 'In order to qualify for a certificate, you must have a passing grade.', defaultMessage: 'In order to qualify for a certificate, you must have a passing grade.',
description: 'Body text when learner certifcate status is not passing',
}, },
inProgressHeader: { inProgressHeader: {
id: 'progress.certificateStatus.inProgressHeader', id: 'progress.certificateStatus.inProgressHeader',
defaultMessage: 'More content is coming soon!', defaultMessage: 'More content is coming soon!',
description: 'Header text when learner certifcate is in progress',
}, },
inProgressBody: { inProgressBody: {
id: 'progress.certificateStatus.inProgressBody', id: 'progress.certificateStatus.inProgressBody',
defaultMessage: 'It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.', defaultMessage: 'It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.',
description: 'Body text when learner certifcate is in progress',
}, },
requestableHeader: { requestableHeader: {
id: 'progress.certificateStatus.requestableHeader', id: 'progress.certificateStatus.requestableHeader',
defaultMessage: 'Certificate status', defaultMessage: 'Certificate status',
description: 'Header text when learner certifcate status is requestable',
}, },
requestableBody: { requestableBody: {
id: 'progress.certificateStatus.requestableBody', id: 'progress.certificateStatus.requestableBody',
defaultMessage: 'Congratulations, you qualified for a certificate! In order to access your certificate, request it below.', defaultMessage: 'Congratulations, you qualified for a certificate! In order to access your certificate, request it below.',
description: 'Body text when learner certifcate status is requestable',
}, },
requestableButton: { requestableButton: {
id: 'progress.certificateStatus.requestableButton', id: 'progress.certificateStatus.requestableButton',
defaultMessage: 'Request certificate', defaultMessage: 'Request certificate',
description: 'Button text when learner certifcate status is requestable',
}, },
unverifiedHeader: { unverifiedHeader: {
id: 'progress.certificateStatus.unverifiedHeader', id: 'progress.certificateStatus.unverifiedHeader',
defaultMessage: 'Certificate status', defaultMessage: 'Certificate status',
description: 'Header text when learner certifcate status is unverified',
}, },
unverifiedButton: { unverifiedButton: {
id: 'progress.certificateStatus.unverifiedButton', id: 'progress.certificateStatus.unverifiedButton',
defaultMessage: 'Verify ID', defaultMessage: 'Verify ID',
description: 'Button text when learner certifcate status is unverified',
}, },
unverifiedPendingBody: { unverifiedPendingBody: {
id: 'progress.certificateStatus.courseCelebration.verificationPending', id: 'progress.certificateStatus.courseCelebration.verificationPending',
defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.', defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.',
description: 'Body text when learner certifcate status is unverified pending',
}, },
downloadableHeader: { downloadableHeader: {
id: 'progress.certificateStatus.downloadableHeader', id: 'progress.certificateStatus.downloadableHeader',
defaultMessage: 'Your certificate is available!', defaultMessage: 'Your certificate is available!',
description: 'Header text when the certifcate is available',
}, },
downloadableBody: { downloadableBody: {
id: 'progress.certificateStatus.downloadableBody', id: 'progress.certificateStatus.downloadableBody',
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.', defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.',
description: 'Recommending an action for learner when course certificate is available', },
downloadableButton: {
id: 'progress.certificateStatus.downloadableButton',
defaultMessage: 'Download my certificate',
}, },
viewableButton: { viewableButton: {
id: 'progress.certificateStatus.viewableButton', id: 'progress.certificateStatus.viewableButton',
defaultMessage: 'View my certificate', defaultMessage: 'View my certificate',
description: 'Button text which view or links to the certifcate',
}, },
notAvailableHeader: { notAvailableHeader: {
id: 'progress.certificateStatus.notAvailableHeader', id: 'progress.certificateStatus.notAvailableHeader',
defaultMessage: 'Certificate status', defaultMessage: 'Certificate status',
description: 'Header text when the certifcate is not available',
},
notAvailableEndDateBody: {
id: 'progress.certificateBody.notAvailable.endDate',
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
description: 'Shown for learners who have finished a course before grades and certificates are available.',
}, },
upgradeHeader: { upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader', id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate', defaultMessage: 'Earn a certificate',
description: 'Header text when the learner needs to upgrade to earn a certifcate ',
}, },
upgradeBody: { upgradeBody: {
id: 'progress.certificateStatus.upgradeBody', id: 'progress.certificateStatus.upgradeBody',
defaultMessage: 'You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.', defaultMessage: 'You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.',
description: 'Body text when the learner needs to upgrade to earn a certifcate ',
}, },
upgradeButton: { upgradeButton: {
id: 'progress.certificateStatus.upgradeButton', id: 'progress.certificateStatus.upgradeButton',
defaultMessage: 'Upgrade now', defaultMessage: 'Upgrade now',
description: 'Button text which leaner needs to upgrade to get the certifcate',
}, },
unverifiedHomeHeader: { unverifiedHomeHeader: {
id: 'progress.certificateStatus.unverifiedHomeHeader.v2', id: 'progress.certificateStatus.unverifiedHomeHeader',
defaultMessage: 'Verify your identity to qualify for a certificate.', defaultMessage: 'Verify your identity to earn a certificate!',
description: 'Header text when the learner needs to do verification to earn a certifcate ',
}, },
unverifiedHomeButton: { unverifiedHomeButton: {
id: 'progress.certificateStatus.unverifiedHomeButton', id: 'progress.certificateStatus.unverifiedHomeButton',
defaultMessage: 'Verify my ID', defaultMessage: 'Verify my ID',
description: 'Button text which leaner needs to do verification to earn a certifcate',
}, },
unverifiedHomeBody: { unverifiedHomeBody: {
id: 'progress.certificateStatus.unverifiedHomeBody', id: 'progress.certificateStatus.unverifiedHomeBody',
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.', defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.',
description: 'Body text when the learner needs to do verification to earn a certifcate',
}, },
}); });

View File

@@ -1,8 +1,6 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
import CompleteDonutSegment from './CompleteDonutSegment'; import CompleteDonutSegment from './CompleteDonutSegment';
@@ -28,8 +26,6 @@ function CompletionDonutChart({ intl }) {
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0; const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
const incompletePercentage = 100 - completePercentage - lockedPercentage; const incompletePercentage = 100 - completePercentage - lockedPercentage;
const isLocaleRtl = isRtl(getLocale());
return ( return (
<> <>
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true"> <svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
@@ -39,7 +35,7 @@ function CompletionDonutChart({ intl }) {
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" /> <circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
<g className="donut-chart-text"> <g className="donut-chart-text">
<text x="50%" y="50%" className="donut-chart-number"> <text x="50%" y="50%" className="donut-chart-number">
{completePercentage}{isLocaleRtl && '\u200f'}% {completePercentage}%
</text> </text>
<text x="50%" y="50%" className="donut-chart-label"> <text x="50%" y="50%" className="donut-chart-label">
{intl.formatMessage(messages.donutLabel)} {intl.formatMessage(messages.donutLabel)}

View File

@@ -6,7 +6,7 @@ import messages from './messages';
function CourseCompletion({ intl }) { function CourseCompletion({ intl }) {
return ( return (
<section className="text-dark-700 mb-4 rounded raised-card p-4"> <section className="text-dark-700 mb-4 rounded shadow-sm p-4">
<div className="row w-100 m-0"> <div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0"> <div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2> <h2>{intl.formatMessage(messages.courseCompletion)}</h2>

View File

@@ -4,47 +4,38 @@ const messages = defineMessages({
donutLabel: { donutLabel: {
id: 'progress.completion.donut.label', id: 'progress.completion.donut.label',
defaultMessage: 'completed', defaultMessage: 'completed',
description: 'Label text for progress donut chart',
}, },
completionBody: { completionBody: {
id: 'progress.completion.body', id: 'progress.completion.body',
defaultMessage: 'This represents how much of the course content you have completed. Note that some content may not yet be released.', defaultMessage: 'This represents how much of the course content you have completed. Note that some content may not yet be released.',
description: 'It explains the meaning of progress donut chart',
}, },
completeContentTooltip: { completeContentTooltip: {
id: 'progress.completion.tooltip.locked', id: 'progress.completion.tooltip.locked',
defaultMessage: 'Content that you have completed.', defaultMessage: 'Content that you have completed.',
description: 'It expalains the meaning of content that is completed',
}, },
courseCompletion: { courseCompletion: {
id: 'progress.completion.header', id: 'progress.completion.header',
defaultMessage: 'Course completion', defaultMessage: 'Course completion',
description: 'Header text for (completion donut chart) section of the progress tab',
}, },
incompleteContentTooltip: { incompleteContentTooltip: {
id: 'progress.completion.tooltip', id: 'progress.completion.tooltip',
defaultMessage: 'Content that you have access to and have not completed.', defaultMessage: 'Content that you have access to and have not completed.',
description: 'It explain the meaning for content is completed',
}, },
lockedContentTooltip: { lockedContentTooltip: {
id: 'progress.completion.tooltip.complete', id: 'progress.completion.tooltip.complete',
defaultMessage: 'Content that is locked and available only to those who upgrade.', defaultMessage: 'Content that is locked and available only to those who upgrade.',
description: 'It expalains the meaning of content that is locked',
}, },
percentComplete: { percentComplete: {
id: 'progress.completion.donut.percentComplete', id: 'progress.completion.donut.percentComplete',
defaultMessage: 'You have completed {percent}% of content in this course.', defaultMessage: 'You have completed {percent}% of content in this course.',
description: 'It summarize the progress in the course (100% - %incomplete)',
}, },
percentIncomplete: { percentIncomplete: {
id: 'progress.completion.donut.percentIncomplete', id: 'progress.completion.donut.percentIncomplete',
defaultMessage: 'You have not completed {percent}% of content in this course that you have access to.', defaultMessage: 'You have not completed {percent}% of content in this course that you have access to.',
description: 'It summarize the progress in the course (100% - %complete)',
}, },
percentLocked: { percentLocked: {
id: 'progress.completion.donut.percentLocked', id: 'progress.completion.donut.percentLocked',
defaultMessage: '{percent}% of content in this course is locked and available only for those who upgrade.', defaultMessage: '{percent}% of content in this course is locked and available only for those who upgrade.',
description: 'It indicate the relative size of content that is locked in the course (100% - %open_content)',
}, },
}); });

View File

@@ -1,115 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled, WatchFilled } from '@edx/paragon/icons';
import { Hyperlink, Icon } from '@edx/paragon';
import { useModel } from '../../../generic/model-store';
import { DashboardLink } from '../../../shared/links';
import messages from './messages';
function CreditInformation({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
creditCourseRequirements,
} = useModel('progress', courseId);
if (!creditCourseRequirements) { return null; }
let eligibilityStatus;
let requirementStatus;
const requirements = [];
const dashboardLink = <DashboardLink />;
const creditLink = (
<Hyperlink
variant="muted"
isInline
destination={getConfig().CREDIT_HELP_LINK_URL}
>{intl.formatMessage(messages.courseCredit)}
</Hyperlink>
);
switch (creditCourseRequirements.eligibilityStatus) {
case 'not_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditNotEligible"
defaultMessage="You are no longer eligible for credit in this course. Learn more about {creditLink}."
description="Message to learner who are not eligible for course credit, it can because the a requirement deadline have passed"
values={{ creditLink }}
/>
);
break;
case 'eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditEligible"
defaultMessage="
You have met the requirements for credit in this course. Go to your
{dashboardLink} to purchase course credit. Or learn more about {creditLink}."
description="After the credit requirements are met, leaners can then do the last step which purchasing the credit. Note that is only doable for leaners after they met all the requirements"
values={{ dashboardLink, creditLink }}
/>
);
break;
case 'partial_eligible':
eligibilityStatus = (
<FormattedMessage
id="progress.creditInformation.creditPartialEligible"
defaultMessage="You have not yet met the requirements for credit. Learn more about {creditLink}."
description="This means that one or more requirements is not satisfied yet"
values={{ creditLink }}
/>
);
break;
default:
break;
}
creditCourseRequirements.requirements.forEach(requirement => {
switch (requirement.status) {
case 'submitted':
requirementStatus = (<>{intl.formatMessage(messages.verificationSubmitted)} <Icon src={CheckCircle} className="text-success-500 d-inline-flex align-bottom" /></>);
break;
case 'failed':
case 'declined':
requirementStatus = (<>{intl.formatMessage(messages.verificationFailed)} <Icon src={WarningFilled} className="d-inline-flex align-bottom" /></>);
break;
case 'satisfied':
requirementStatus = (<>{intl.formatMessage(messages.completed)} <Icon src={CheckCircle} className="text-success-500 d-inline-flex align-bottom" /></>);
break;
default:
requirementStatus = (<>{intl.formatMessage(messages.upcoming)} <Icon src={WatchFilled} className="text-gray-500 d-inline-flex align-bottom" /></>);
}
requirements.push((
<div className="row w-100 m-0 small" key={`requirement-${requirement.order}`}>
<p className="font-weight-bold">
{requirement.namespace === 'grade'
? `${intl.formatMessage(messages.minimumGrade, { minGrade: Number(requirement.criteria.minGrade) * 100 })}:`
: `${requirement.displayName}:`}
</p>
<div className="ml-1">
{requirementStatus}
</div>
</div>
));
});
return (
<>
<h3 className="h4 col-12 p-0">{intl.formatMessage(messages.requirementsHeader)}</h3>
<p className="small">{eligibilityStatus}</p>
{requirements}
</>
);
}
CreditInformation.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CreditInformation);

View File

@@ -1,40 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'progress.creditInformation.completed',
defaultMessage: 'Completed',
description: 'Label text if a requirement for (course credit) is satisfied',
},
courseCredit: {
id: 'progress.creditInformation.courseCredit',
defaultMessage: 'course credit',
description: 'Anchor text for link that redirects (course credit) help page',
},
minimumGrade: {
id: 'progress.creditInformation.minimumGrade',
defaultMessage: 'Minimum grade for credit ({minGrade}%)',
},
requirementsHeader: {
id: 'progress.creditInformation.requirementsHeader',
defaultMessage: 'Requirements for course credit',
description: 'Header for the requirements section in course credit',
},
upcoming: {
id: 'progress.creditInformation.upcoming',
defaultMessage: 'Upcoming',
description: 'It indicate that the a (credit requirement) status is not known yet',
},
verificationFailed: {
id: 'progress.creditInformation.verificationFailed',
defaultMessage: 'Verification failed',
description: 'It indicate that the learner submitted a requirement but is either failed or declined',
},
verificationSubmitted: {
id: 'progress.creditInformation.verificationSubmitted',
defaultMessage: 'Verification submitted',
description: 'It indicate that the learner submitted a requirement but is not graded or reviewed yet',
},
});
export default messages;

View File

@@ -7,7 +7,6 @@ import { useModel } from '../../../../generic/model-store';
import CourseGradeFooter from './CourseGradeFooter'; import CourseGradeFooter from './CourseGradeFooter';
import CourseGradeHeader from './CourseGradeHeader'; import CourseGradeHeader from './CourseGradeHeader';
import GradeBar from './GradeBar'; import GradeBar from './GradeBar';
import CreditInformation from '../../credit-information/CreditInformation';
import messages from '../messages'; import messages from '../messages';
@@ -17,7 +16,6 @@ function CourseGrade({ intl }) {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
creditCourseRequirements,
gradesFeatureIsFullyLocked, gradesFeatureIsFullyLocked,
gradesFeatureIsPartiallyLocked, gradesFeatureIsPartiallyLocked,
gradingPolicy: { gradingPolicy: {
@@ -30,24 +28,18 @@ function CourseGrade({ intl }) {
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
return ( return (
<section className="text-dark-700 my-4 rounded raised-card"> <section className="text-dark-700 my-4 rounded shadow-sm">
{(gradesFeatureIsFullyLocked || gradesFeatureIsPartiallyLocked) && <CourseGradeHeader />} {(gradesFeatureIsFullyLocked || gradesFeatureIsPartiallyLocked) && <CourseGradeHeader />}
<div className={applyLockedOverlay} aria-hidden={gradesFeatureIsFullyLocked}> <div className={applyLockedOverlay} aria-hidden={gradesFeatureIsFullyLocked}>
<div className="row w-100 m-0 p-4"> <div className="row w-100 m-0 p-4">
<div className="col-12 col-sm-6 p-0 pr-sm-5.5"> <div className="col-12 col-sm-6 p-0 pr-sm-2">
<h2>{creditCourseRequirements <h2>{intl.formatMessage(messages.grades)}</h2>
? intl.formatMessage(messages.gradesAndCredit)
: intl.formatMessage(messages.grades)}
</h2>
<p className="small"> <p className="small">
{intl.formatMessage(messages.courseGradeBody)} {intl.formatMessage(messages.courseGradeBody)}
</p> </p>
</div> </div>
<GradeBar passingGrade={passingGrade} /> <GradeBar passingGrade={passingGrade} />
</div> </div>
<div className="row w-100 m-0 px-4">
<CreditInformation />
</div>
<CourseGradeFooter passingGrade={passingGrade} /> <CourseGradeFooter passingGrade={passingGrade} />
</div> </div>
</section> </section>

View File

@@ -2,9 +2,11 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { layoutGenerator } from 'react-break';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { CheckCircle, WarningFilled } from '@edx/paragon/icons'; import { CheckCircle, WarningFilled } from '@edx/paragon/icons';
import { breakpoints, Icon, useWindowSize } from '@edx/paragon'; import { Icon } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import GradeRangeTooltip from './GradeRangeTooltip'; import GradeRangeTooltip from './GradeRangeTooltip';
@@ -25,7 +27,13 @@ function CourseGradeFooter({ intl, passingGrade }) {
}, },
} = useModel('progress', courseId); } = useModel('progress', courseId);
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; const layout = layoutGenerator({
mobile: 0,
tablet: 768,
});
const OnMobile = layout.is('mobile');
const OnAtLeastTablet = layout.isAtLeast('tablet');
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade }); let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
@@ -58,7 +66,7 @@ function CourseGradeFooter({ intl, passingGrade }) {
{icon} {icon}
</div> </div>
<div className="col-11 pl-2 px-0"> <div className="col-11 pl-2 px-0">
{!wideScreen && ( <OnMobile>
<span className="h5 align-bottom"> <span className="h5 align-bottom">
{footerText} {footerText}
{hasLetterGrades && ( {hasLetterGrades && (
@@ -68,8 +76,8 @@ function CourseGradeFooter({ intl, passingGrade }) {
</span> </span>
)} )}
</span> </span>
)} </OnMobile>
{wideScreen && ( <OnAtLeastTablet>
<span className="h4 m-0 align-bottom"> <span className="h4 m-0 align-bottom">
{footerText} {footerText}
{hasLetterGrades && ( {hasLetterGrades && (
@@ -79,7 +87,7 @@ function CourseGradeFooter({ intl, passingGrade }) {
</span> </span>
)} )}
</span> </span>
)} </OnAtLeastTablet>
</div> </div>
</div> </div>
); );

View File

@@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@edx/paragon'; import { OverlayTrigger, Popover } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -19,19 +17,11 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
const { const {
courseGrade: { courseGrade: {
isPassing, isPassing,
percent, visiblePercent,
}, },
} = useModel('progress', courseId); } = useModel('progress', courseId);
const currentGrade = Number((percent * 100).toFixed(0)); const currentGrade = Number((visiblePercent * 100).toFixed(0));
let currentGradeDirection = currentGrade < 50 ? '' : '-';
const isLocaleRtl = isRtl(getLocale());
if (isLocaleRtl) {
currentGradeDirection = currentGrade < 50 ? '-' : '';
}
return ( return (
<> <>
@@ -41,22 +31,22 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
overlay={( overlay={(
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}> <Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
<Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}> <Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}>
{currentGrade.toFixed(0)}{isLocaleRtl ? '\u200f' : ''}% {currentGrade.toFixed(0)}%
</Popover.Content> </Popover.Content>
</Popover> </Popover>
)} )}
> >
<g> <g>
<circle cx={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`} cy="50%" r="8.5" fill="transparent" /> <circle cx={`${Math.min(...[currentGrade, 100])}%`} cy="50%" r="8.5" fill="transparent" />
<rect className="grade-bar__divider" x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`} style={{ transform: 'translateY(2.61em)' }} /> <rect className="grade-bar__divider" x={`${Math.min(...[currentGrade, 100])}%`} style={{ transform: 'translateY(2.61em)' }} />
</g> </g>
</OverlayTrigger> </OverlayTrigger>
<text <text
className="x-small" className="x-small"
textAnchor={currentGrade < 50 ? 'start' : 'end'} textAnchor={currentGrade < 50 ? 'start' : 'end'}
x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`} x={`${Math.min(...[currentGrade, 100])}%`}
y="20px" y="20px"
style={{ transform: `translateX(${currentGradeDirection}3.4em)` }} style={{ transform: `translateX(${currentGrade < 50 ? '' : '-'}3.4em)` }}
> >
{intl.formatMessage(messages.currentGradeLabel)} {intl.formatMessage(messages.currentGradeLabel)}
</text> </text>

View File

@@ -2,9 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import CurrentGradeTooltip from './CurrentGradeTooltip'; import CurrentGradeTooltip from './CurrentGradeTooltip';
import PassingGradeTooltip from './PassingGradeTooltip'; import PassingGradeTooltip from './PassingGradeTooltip';
@@ -19,25 +17,23 @@ function GradeBar({ intl, passingGrade }) {
const { const {
courseGrade: { courseGrade: {
isPassing, isPassing,
percent, visiblePercent,
}, },
gradesFeatureIsFullyLocked, gradesFeatureIsFullyLocked,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const currentGrade = Number((percent * 100).toFixed(0)); const currentGrade = Number((visiblePercent * 100).toFixed(0));
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const adjustedRtlStyle = (percentOffest) => (isRtl(getLocale()) ? { transform: `translateX(${100 - percentOffest}%)` } : {});
return ( return (
<div className="col-12 col-sm-6 align-self-center p-0"> <div className="col-12 col-sm-6 align-self-center">
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div> <div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true"> <svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
<g style={{ transform: 'translateY(2.61em)' }}> <g style={{ transform: 'translateY(2.61em)' }}>
<rect className="grade-bar__base" width="100%" /> <rect className="grade-bar__base" width="100%" />
<rect className="grade-bar--passing" width={`${passingGrade}%`} style={adjustedRtlStyle(passingGrade)} /> <rect className="grade-bar--passing" width={`${passingGrade}%`} />
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} style={adjustedRtlStyle(currentGrade)} /> <rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} />
{/* Start divider */} {/* Start divider */}
<rect className="grade-bar__divider" /> <rect className="grade-bar__divider" />

View File

@@ -1,22 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { OverlayTrigger, Popover } from '@edx/paragon'; import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from '../messages'; import messages from '../messages';
function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) { function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
const isLocaleRtl = isRtl(getLocale());
let passingGradeDirection = passingGrade < 50 ? '' : '-';
if (isLocaleRtl) {
passingGradeDirection = passingGrade < 50 ? '-' : '';
}
return ( return (
<> <>
<OverlayTrigger <OverlayTrigger
@@ -25,23 +15,23 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
overlay={( overlay={(
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true"> <Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
<Popover.Content className="text-white"> <Popover.Content className="text-white">
{passingGrade}{isLocaleRtl && '\u200f'}% {passingGrade}%
</Popover.Content> </Popover.Content>
</Popover> </Popover>
)} )}
> >
<g> <g>
<circle cx={`${isLocaleRtl ? 100 - passingGrade : passingGrade}%`} cy="50%" r="8.5" fill="transparent" /> <circle cx={`${passingGrade}%`} cy="50%" r="8.5" fill="transparent" />
<circle className="grade-bar--passing" cx={`${isLocaleRtl ? 100 - passingGrade : passingGrade}%`} cy="50%" r="4.5" /> <circle className="grade-bar--passing" cx={`${passingGrade}%`} cy="50%" r="4.5" />
</g> </g>
</OverlayTrigger> </OverlayTrigger>
<text <text
className="x-small" className="x-small"
textAnchor={passingGrade < 50 ? 'start' : 'end'} textAnchor={passingGrade < 50 ? 'start' : 'end'}
x={`${isLocaleRtl ? 100 - passingGrade : passingGrade}%`} x={`${passingGrade}%`}
y="90px" y="90px"
style={{ transform: `translateX(${passingGradeDirection}3.4em)` }} style={{ transform: `translateX(${passingGrade < 50 ? '' : '-'}3.4em)` }}
> >
{intl.formatMessage(messages.passingGradeLabel)} {intl.formatMessage(messages.passingGradeLabel)}
</text> </text>

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -19,7 +20,6 @@ function DetailedGrades({ intl }) {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
org, org,
tabs,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
gradesFeatureIsFullyLocked, gradesFeatureIsFullyLocked,
@@ -37,14 +37,10 @@ function DetailedGrades({ intl }) {
}); });
}; };
const overviewTab = tabs.find(tab => tab.slug === 'outline'); const outlineLink = (
const overviewTabUrl = overviewTab && overviewTab.url;
const outlineLink = overviewTabUrl && (
<Hyperlink <Hyperlink
variant="muted" className="muted-link inline-link"
isInline destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
destination={overviewTabUrl}
onClick={logOutlineLinkClick} onClick={logOutlineLinkClick}
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'} tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
> >
@@ -67,16 +63,13 @@ function DetailedGrades({ intl }) {
{!hasSectionScores && ( {!hasSectionScores && (
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p> <p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
)} )}
{overviewTabUrl && ( <p className="x-small m-0">
<p className="x-small m-0"> <FormattedMessage
<FormattedMessage id="progress.ungradedAlert"
id="progress.ungradedAlert" defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}." values={{ outlineLink }}
description="Text that precede link that redirect to course outline page" />
values={{ outlineLink }} </p>
/>
</p>
)}
</section> </section>
); );
} }

View File

@@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -19,7 +17,6 @@ function DetailedGradesTable({ intl }) {
sectionScores, sectionScores,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const isLocaleRtl = isRtl(getLocale());
return ( return (
sectionScores.map((chapter) => { sectionScores.map((chapter) => {
const subsectionScores = chapter.subsections.filter( const subsectionScores = chapter.subsections.filter(
@@ -35,7 +32,7 @@ function DetailedGradesTable({ intl }) {
const detailedGradesData = subsectionScores.map((subsection) => ({ const detailedGradesData = subsectionScores.map((subsection) => ({
subsectionTitle: <SubsectionTitleCell subsection={subsection} />, subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}{isLocaleRtl ? '\\' : '/'}{subsection.numPointsPossible}</span>, score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}/{subsection.numPointsPossible}</span>,
})); }));
return ( return (

View File

@@ -2,21 +2,18 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import messages from '../messages'; import messages from '../messages';
function ProblemScoreDrawer({ intl, problemScores, subsection }) { function ProblemScoreDrawer({ intl, problemScores, subsection }) {
const isLocaleRtl = isRtl(getLocale());
return ( return (
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap"> <span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span> <span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}> <div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label"> <ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
{problemScores.map(problemScore => ( {problemScores.map(problemScore => (
<li className="ml-3">{problemScore.earned}{isLocaleRtl ? '\\' : '/'}{problemScore.possible}</li> <li className="ml-3">{problemScore.earned}/{problemScore.possible}</li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -6,9 +6,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Row } from '@edx/paragon'; import { Collapsible, Icon, Row } from '@edx/paragon';
import { import { ArrowDropDown, ArrowDropUp, Blocked } from '@edx/paragon/icons';
ArrowDropDown, ArrowDropUp, Blocked, Info,
} from '@edx/paragon/icons';
import messages from '../messages'; import messages from '../messages';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -81,21 +79,7 @@ function SubsectionTitleCell({ intl, subsection }) {
</span> </span>
</Row> </Row>
<Collapsible.Body className="d-flex w-100"> <Collapsible.Body className="d-flex w-100">
<div className="col"> <ProblemScoreDrawer problemScores={problemScores} subsection={subsection} />
{ subsection.override && (
<div className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
<div>
<Icon
src={Info}
className="x-small mr-1 text-primary-500"
style={{ height: '1.3em', width: '1.3em' }}
/>
</div>
<div>{intl.formatMessage(messages.sectionGradeOverridden)}</div>
</div>
)}
<ProblemScoreDrawer problemScores={problemScores} subsection={subsection} />
</div>
</Collapsible.Body> </Collapsible.Body>
</Collapsible.Advanced> </Collapsible.Advanced>
); );
@@ -103,20 +87,7 @@ function SubsectionTitleCell({ intl, subsection }) {
SubsectionTitleCell.propTypes = { SubsectionTitleCell.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,
subsection: PropTypes.shape({ subsection: PropTypes.shape.isRequired,
blockKey: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
learnerHasAccess: PropTypes.bool.isRequired,
override: PropTypes.shape({
system: PropTypes.string,
reason: PropTypes.string,
}),
problemScores: PropTypes.arrayOf(PropTypes.shape({
earned: PropTypes.number.isRequired,
possible: PropTypes.number.isRequired,
})).isRequired,
url: PropTypes.string,
}).isRequired,
}; };
export default injectIntl(SubsectionTitleCell); export default injectIntl(SubsectionTitleCell);

View File

@@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -66,17 +64,15 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
footnoteMarker = footnotes.length; footnoteMarker = footnotes.length;
} }
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type); const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type) ? 'greyed-out' : '';
const isLocaleRtl = isRtl(getLocale());
return { return {
type: { type: {
footnoteId, footnoteMarker, type: assignment.type, locked, footnoteId, footnoteMarker, type: assignment.type, locked,
}, },
weight: { weight: `${(assignment.weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, weight: { weight: `${(assignment.weight * 100).toFixed(0)}%`, locked },
grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}%`, locked },
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}%`, locked },
}; };
}); });

View File

@@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { DataTable } from '@edx/paragon'; import { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
@@ -17,20 +15,18 @@ function GradeSummaryTableFooter({ intl }) {
const { const {
courseGrade: { courseGrade: {
isPassing, isPassing,
percent, visiblePercent,
}, },
} = useModel('progress', courseId); } = useModel('progress', courseId);
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100'; const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
const totalGrade = (percent * 100).toFixed(0); const totalGrade = (visiblePercent * 100).toFixed(0);
const isLocaleRtl = isRtl(getLocale());
return ( return (
<DataTable.TableFooter className={`border-top border-primary ${bgColor}`}> <DataTable.TableFooter className={`border-top border-primary ${bgColor}`}>
<div className="row w-100 m-0"> <div className="row w-100 m-0">
<div id="weighted-grade-summary" className="col-8 p-0 small">{intl.formatMessage(messages.weightedGradeSummary)}</div> <div id="weighted-grade-summary" className="col-8 p-0 small">{intl.formatMessage(messages.weightedGradeSummary)}</div>
<div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}{isLocaleRtl && '\u200f'}%</div> <div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}%</div>
</div> </div>
</DataTable.TableFooter> </DataTable.TableFooter>
); );

View File

@@ -4,196 +4,149 @@ const messages = defineMessages({
assignmentType: { assignmentType: {
id: 'progress.assignmentType', id: 'progress.assignmentType',
defaultMessage: 'Assignment type', defaultMessage: 'Assignment type',
description: 'Header for column that indicate type of the assignment in grade summary table',
}, },
backToContent: { backToContent: {
id: 'progress.footnotes.backToContent', id: 'progress.footnotes.backToContent',
defaultMessage: 'Back to content', defaultMessage: 'Back to content',
description: 'Text for button that redirects to contnet',
}, },
courseGradeBody: { courseGradeBody: {
id: 'progress.courseGrade.body', id: 'progress.courseGrade.body',
defaultMessage: 'This represents your weighted grade against the grade needed to pass this course.', defaultMessage: 'This represents your weighted grade against the grade needed to pass this course.',
description: 'This text is shown to explain the meaning of the (grade bar) chart',
}, },
courseGradeBarAltText: { courseGradeBarAltText: {
id: 'progress.courseGrade.gradeBar.altText', id: 'progress.courseGrade.gradeBar.altText',
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.', defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
description: 'Alt text for the grade chart bar',
}, },
courseGradeFooterGenericPassing: { courseGradeFooterGenericPassing: {
id: 'progress.courseGrade.footer.generic.passing', id: 'progress.courseGrade.footer.generic.passing',
defaultMessage: 'Youre currently passing this course', defaultMessage: 'Youre currently passing this course',
description: 'This shown when learner weighted grade is greater or equal course passing grade',
}, },
courseGradeFooterNonPassing: { courseGradeFooterNonPassing: {
id: 'progress.courseGrade.footer.nonPassing', id: 'progress.courseGrade.footer.nonPassing',
defaultMessage: 'A weighted grade of {passingGrade}% is required to pass in this course', defaultMessage: 'A weighted grade of {passingGrade}% is required to pass in this course',
description: 'This shown when learner weighted grade is less than course passing grade',
}, },
courseGradeFooterPassingWithGrade: { courseGradeFooterPassingWithGrade: {
id: 'progress.courseGrade.footer.passing', id: 'progress.courseGrade.footer.passing',
defaultMessage: 'Youre currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)', defaultMessage: 'Youre currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)',
description: 'This shown when learner weighted grade is greater or equal course passing grade amd course is using letter grade',
}, },
courseGradePreviewHeaderLocked: { courseGradePreviewHeaderLocked: {
id: 'progress.courseGrade.preview.headerLocked', id: 'progress.courseGrade.preview.headerLocked',
defaultMessage: 'locked feature', defaultMessage: 'locked feature',
description: 'This when (progress page) feature is locked, sometimes learner needs to upgrade to get insight about their progress',
}, },
courseGradePreviewHeaderLimited: { courseGradePreviewHeaderLimited: {
id: 'progress.courseGrade.preview.headerLimited', id: 'progress.courseGrade.preview.headerLimited',
defaultMessage: 'limited feature', defaultMessage: 'limited feature',
description: 'This when (progress page) feature is partially locked, it means leaners can see their progress but not get to a certificate',
}, },
courseGradePreviewHeaderAriaHidden: { courseGradePreviewHeaderAriaHidden: {
id: 'progress.courseGrade.preview.header.ariaHidden', id: 'progress.courseGrade.preview.header.ariaHidden',
defaultMessage: 'Preview of a ', defaultMessage: 'Preview of a ',
description: 'This text precedes either (locked feature) or (limited feature)',
}, },
courseGradePreviewUnlockCertificateBody: { courseGradePreviewUnlockCertificateBody: {
id: 'progress.courseGrade.preview.body.unlockCertificate', id: 'progress.courseGrade.preview.body.unlockCertificate',
defaultMessage: 'Unlock to view grades and work towards a certificate.', defaultMessage: 'Unlock to view grades and work towards a certificate.',
description: 'Recommending an action for learner when they need to upgrade to view progress and get a certificate',
}, },
courseGradePartialPreviewUnlockCertificateBody: { courseGradePartialPreviewUnlockCertificateBody: {
id: 'progress.courseGrade.partialpreview.body.unlockCertificate', id: 'progress.courseGrade.partialpreview.body.unlockCertificate',
defaultMessage: 'Unlock to work towards a certificate.', defaultMessage: 'Unlock to work towards a certificate.',
description: 'Recommending an action for learner when they need to upgrade to get a certificate',
}, },
courseGradePreviewUpgradeDeadlinePassedBody: { courseGradePreviewUpgradeDeadlinePassedBody: {
id: 'progress.courseGrade.preview.body.upgradeDeadlinePassed', id: 'progress.courseGrade.preview.body.upgradeDeadlinePassed',
defaultMessage: 'The deadline to upgrade in this course has passed.', defaultMessage: 'The deadline to upgrade in this course has passed.',
description: 'Shown when learner no longer can upgrade',
}, },
courseGradePreviewUpgradeButton: { courseGradePreviewUpgradeButton: {
id: 'progress.courseGrade.preview.button.upgrade', id: 'progress.courseGrade.preview.button.upgrade',
defaultMessage: 'Upgrade now', defaultMessage: 'Upgrade now',
description: 'Text for button that redirects to the upgrade page',
}, },
courseGradeRangeTooltip: { courseGradeRangeTooltip: {
id: 'progress.courseGrade.gradeRange.tooltip', id: 'progress.courseGrade.gradeRange.tooltip',
defaultMessage: 'Grade ranges for this course:', defaultMessage: 'Grade ranges for this course:',
description: 'This shown when course is using (letter grade) to explain e.g. range for A, B, and C...etc',
}, },
courseOutline: { courseOutline: {
id: 'progress.courseOutline', id: 'progress.courseOutline',
defaultMessage: 'Course Outline', defaultMessage: 'Course Outline',
description: 'Anchor text for link that redirects to (course outline) tab',
}, },
currentGradeLabel: { currentGradeLabel: {
id: 'progress.courseGrade.label.currentGrade', id: 'progress.courseGrade.label.currentGrade',
defaultMessage: 'Your current grade', defaultMessage: 'Your current grade',
description: 'Text label current leaner grade on (grade bar) chart',
}, },
detailedGrades: { detailedGrades: {
id: 'progress.detailedGrades', id: 'progress.detailedGrades',
defaultMessage: 'Detailed grades', defaultMessage: 'Detailed grades',
description: 'Headline for the (detailed grade) section in the progress tab',
}, },
detailedGradesEmpty: { detailedGradesEmpty: {
id: 'progress.detailedGrades.emptyTable', id: 'progress.detailedGrades.emptyTable',
defaultMessage: 'You currently have no graded problem scores.', defaultMessage: 'You currently have no graded problem scores.',
description: 'It indicate that there are no problem or assignments to be scored',
}, },
footnotesTitle: { footnotesTitle: {
id: 'progress.footnotes.title', id: 'progress.footnotes.title',
defaultMessage: 'Grade summary footnotes', defaultMessage: 'Grade summary footnotes',
description: 'Title for grade summary footnotes, if exists',
}, },
grade: { grade: {
id: 'progress.gradeSummary.grade', id: 'progress.gradeSummary.grade',
defaultMessage: 'Grade', defaultMessage: 'Grade',
description: 'Headline for (grade column) in grade summary table',
}, },
grades: { grades: {
id: 'progress.courseGrade.grades', id: 'progress.courseGrade.grades',
defaultMessage: 'Grades', defaultMessage: 'Grades',
description: 'Headline for grades section in progress tab',
},
gradesAndCredit: {
id: 'progress.courseGrade.gradesAndCredit',
defaultMessage: 'Grades & Credit',
description: 'Headline for (grades and credit) section in progress tab',
}, },
gradeRangeTooltipAlt: { gradeRangeTooltipAlt: {
id: 'progress.courseGrade.gradeRange.Tooltip', id: 'progress.courseGrade.gradeRange.Tooltip',
defaultMessage: 'Grade range tooltip', defaultMessage: 'Grade range tooltip',
description: 'Alt text for icon which that triggers (tip box) for grade range',
}, },
gradeSummary: { gradeSummary: {
id: 'progress.gradeSummary', id: 'progress.gradeSummary',
defaultMessage: 'Grade summary', defaultMessage: 'Grade summary',
description: 'Headline for the (grade summary) section in (grades) section in progress tab',
}, },
gradeSummaryLimitedAccessExplanation: { gradeSummaryLimitedAccessExplanation: {
id: 'progress.gradeSummary.limitedAccessExplanation', id: 'progress.gradeSummary.limitedAccessExplanation',
defaultMessage: 'You have limited access to graded assignments as part of the audit track in this course.', defaultMessage: 'You have limited access to graded assignments as part of the audit track in this course.',
description: 'Text shown when learner has limited access to grade feature',
}, },
gradeSummaryTooltipAlt: { gradeSummaryTooltipAlt: {
id: 'progress.gradeSummary.tooltip.alt', id: 'progress.gradeSummary.tooltip.alt',
defaultMessage: 'Grade summary tooltip', defaultMessage: 'Grade summary tooltip',
description: 'Alt text for icon which that triggers (tip box) for grade summary',
}, },
gradeSummaryTooltipBody: { gradeSummaryTooltipBody: {
id: 'progress.gradeSummary.tooltip.body', id: 'progress.gradeSummary.tooltip.body',
defaultMessage: "Your course assignment's weight is determined by your instructor. " defaultMessage: "Your course assignment's weight is determined by your instructor. "
+ 'By multiplying your grade by the weight for that assignment type, your weighted grade is calculated. ' + 'By multiplying your grade by the weight for that assignment type, your weighted grade is calculated. '
+ "Your weighted grade is what's used to determine if you pass the course.", + "Your weighted grade is what's used to determine if you pass the course.",
description: 'The content of (tip box) for the grade summary section',
},
noAccessToAssignmentType: {
id: 'progress.noAcessToAssignmentType',
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
description: 'Its alt text for locked icon which is shown if assignment type in (grade summary table) is locked',
},
noAccessToSubsection: {
id: 'progress.noAcessToSubsection',
defaultMessage: 'You do not have access to subsection {displayName}',
description: 'Text shown when learner have limited access to grades feature',
}, },
passingGradeLabel: { passingGradeLabel: {
id: 'progress.courseGrade.label.passingGrade', id: 'progress.courseGrade.label.passingGrade',
defaultMessage: 'Passing grade', defaultMessage: 'Passing grade',
description: 'Label for mark on the (grade bar) chart which indicate the poisition of passing grade on the bar',
}, },
problemScoreLabel: { problemScoreLabel: {
id: 'progress.detailedGrades.problemScore.label', id: 'progress.detailedGrades.problemScore.label',
defaultMessage: 'Problem Scores:', defaultMessage: 'Problem Scores:',
description: 'Label text which precedes detailed view of all scores per assignment',
}, },
problemScoreToggleAltText: { problemScoreToggleAltText: {
id: 'progress.detailedGrades.problemScore.toggleButton', id: 'progress.detailedGrades.problemScore.toggleButton',
defaultMessage: 'Toggle individual problem scores for {subsectionTitle}', defaultMessage: 'Toggle individual problem scores for {subsectionTitle}',
description: 'Alt text for button which switches detailed view per module',
},
sectionGradeOverridden: {
id: 'progress.detailedGrades.overridden',
defaultMessage: 'Section grade has been overridden.',
description: 'This indicate that the graded score has been changed, it can happen if leaner initial assessment was not fair, might be for other reasons as well',
}, },
score: { score: {
id: 'progress.score', id: 'progress.score',
defaultMessage: 'Score', defaultMessage: 'Score',
description: 'It indicate how many points the learner have socred scored in particular assignment, or exam',
}, },
weight: { weight: {
id: 'progress.weight', id: 'progress.weight',
defaultMessage: 'Weight', defaultMessage: 'Weight',
description: 'It indicate the weight of particular assignment on overall course grade, it is demeterined by course author',
}, },
weightedGrade: { weightedGrade: {
id: 'progress.weightedGrade', id: 'progress.weightedGrade',
defaultMessage: 'Weighted grade', defaultMessage: 'Weighted grade',
description: 'Weighed grade is calculated by (weight %) * (grade score) ',
}, },
weightedGradeSummary: { weightedGradeSummary: {
id: 'progress.weightedGradeSummary', id: 'progress.weightedGradeSummary',
defaultMessage: 'Your current weighted grade summary', defaultMessage: 'Your current weighted grade summary',
description: 'It the text precede the sum of weighted grades of all the assignment',
}, },
noAccessToAssignmentType: {
id: 'progress.noAcessToAssignmentType',
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
},
noAccessToSubsection: {
id: 'progress.noAcessToSubsection',
defaultMessage: 'You do not have access to subsection {displayName}',
},
}); });
export default messages; export default messages;

View File

@@ -4,7 +4,6 @@ const messages = defineMessages({
progressHeader: { progressHeader: {
id: 'progress.header', id: 'progress.header',
defaultMessage: 'Your progress', defaultMessage: 'Your progress',
description: 'Headline or title for the progress tab',
}, },
progressHeaderForTargetUser: { progressHeaderForTargetUser: {
id: 'progress.header.targetUser', id: 'progress.header.targetUser',
@@ -14,7 +13,6 @@ const messages = defineMessages({
studioLink: { studioLink: {
id: 'progress.link.studio', id: 'progress.link.studio',
defaultMessage: 'View grading in Studio', defaultMessage: 'View grading in Studio',
description: 'Text shown for button that redirects to the studio if the user is a staff memember',
}, },
}); });

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