Compare commits

..

1 Commits

Author SHA1 Message Date
Dillon Dumesnil
bde0a80cf0 fix: A couple of fixes for lilac (#422)
* fix: pass username into proctoring info panel (#406)

Pass a username into the proctoring info panel, allowing staff
to view a specific learner's onboarding status while masquerading.

* fix(i18n): update translations

* AA-720: Progress Tab Course Completion chart (#407)

* chore(deps): update dependency codecov to v3.8.1

* [REV-2127] feat: update gated content lock screen to Value Prop designs (#394)

* fix(i18n): update translations

* fix: allow media access through unit iframe (#412)

Set the `allow` attribute of the unit iframe to allow
access to camera, MIDI, location, and encrpyted media.

Access to these features was implicitly allowed in older
browser versions. However, in the current versions of
at least Chromium and Firefox, iframed content must be
explicitly granted the ability to request media access.

This fixes a bug where content requiring microphone
access did not work in the Learning MFE.

TNL-7675

* fix: AA-738: Switch our use of FormattedTime to use hourCycle (#418)

We had a bug reported where learners were seeing a due date like
March 24, 24:59 instead of March 25, 00:59. This is a bug that only
shows up in Chrome. The hour12 flag overrides the hourCycle flag so
we are just going to swap the two. h23 means a 24 hour format ranging
from 0-23 (there also exists a h24 option which goes from 1-24).

See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
for any additional details on the options.

* feat: Switch to default values for 12 vs 24 hour time. (#420)

Our current version on react-intl doesn't support hourCycle
anyway and after speaking to product, we feel comfortable with
letting it default based on locale.

* fix: AA-663: Update header text for CourseCompletion

If the marketing url is not set, we shouldn't have a message
about sharing.

Co-authored-by: Bianca Severino <bseverino@edx.org>
Co-authored-by: edX Transifex Bot <learner-engineering@edx.org>
Co-authored-by: Carla Duarte <cduarte@edx.org>
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: stvn <stvn@mit.edu>
Co-authored-by: Diane Kaplan <dianekaplan@gmail.com>
Co-authored-by: Kyle McCormick <kmccormick@edx.org>
2021-04-26 07:54:12 -07:00
245 changed files with 11245 additions and 21190 deletions

70
.env
View File

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

View File

@@ -1,16 +1,11 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
@@ -24,7 +19,7 @@ ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
SEGMENT_KEY=null
SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
@@ -32,9 +27,6 @@ SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
ENABLE_NOTICES=''

View File

@@ -1,16 +1,11 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
@@ -24,7 +19,7 @@ ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
SEGMENT_KEY=null
SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
@@ -32,8 +27,6 @@ SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_NOTICES=''

View File

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

View File

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

View File

@@ -11,11 +11,11 @@ jobs:
- 12
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

17497
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,15 @@
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
@@ -32,51 +36,47 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-enterprise-utils": "1.0.0",
"@edx/frontend-lib-special-exams": "1.13.3",
"@edx/frontend-platform": "1.12.7",
"@edx/paragon": "16.13.3",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.15",
"@pact-foundation/pact": "9.16.3",
"@reduxjs/toolkit": "1.6.2",
"classnames": "2.3.1",
"core-js": "3.16.4",
"js-cookie": "2.2.1",
"lodash.camelcase": "4.3.0",
"@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-platform": "1.8.4",
"@edx/paragon": "13.17.3",
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/react-fontawesome": "0.1.14",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
"lodash.camelcase": "^4.3.0",
"prop-types": "15.7.2",
"react": "17.0.2",
"react": "16.13.1",
"react-break": "1.3.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.5",
"react-router": "5.2.1",
"react-router-dom": "5.2.1",
"react-share": "4.4.0",
"redux": "4.1.1",
"regenerator-runtime": "0.13.9",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"react-redux": "7.2.2",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-share": "4.2.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"reselect": "4.0.0",
"truncate-html": "1.0.4",
"util": "0.12.4"
"truncate-html": "1.0.3"
},
"devDependencies": {
"@edx/frontend-build": "8.0.4",
"@edx/frontend-build": "5.5.5",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "13.2.1",
"axios-mock-adapter": "1.20.0",
"codecov": "3.8.3",
"es-check": "6.0.0",
"glob": "7.1.7",
"husky": "7.0.2",
"jest": "27.0.6",
"@testing-library/user-event": "12.0.17",
"axios-mock-adapter": "1.18.2",
"codecov": "3.8.1",
"es-check": "5.1.4",
"glob": "7.1.6",
"husky": "3.1.0",
"jest": "24.9.0",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.1.0"
"rosie": "2.0.1"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -4,9 +4,9 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
@@ -35,10 +35,34 @@ function AccessExpirationAlert({ intl, payload }) {
const {
expirationDate,
masqueradingExpiredCourse,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
if (masqueradingExpiredCourse) {
return (
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.accessExpiration.expired"
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
values={{
date: (
<FormattedDate
key="accessExpirationExpiredDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (
@@ -92,7 +116,7 @@ function AccessExpirationAlert({ intl, payload }) {
}
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold">
<FormattedMessage
id="learning.accessExpiration.header"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
function CourseStartMasqueradeBanner({ payload }) {
const {
courseId,
} = payload;
const {
start,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<PageBanner variant="warning">
<FormattedMessage
id="instructorToolbar.pageBanner.courseHasNotStarted"
defaultMessage="This learner does not yet have access to this course. The course starts on {date}."
values={{
date: <FormattedDate
key="instructorToolbar.pageBanner.courseStartDate"
value={start}
{...timezoneFormatArgs}
/>,
}}
/>
</PageBanner>
);
}
CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string.isRequired,
}).isRequired,
};
export default CourseStartMasqueradeBanner;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,135 +0,0 @@
import React, { useState } from 'react';
import Cookies from 'js-cookie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
AlertModal,
Button,
Spinner,
Icon,
} from '@edx/paragon';
import { Check, ArrowForward } from '@edx/paragon/icons';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { sendActivationEmail } from '../../courseware/data';
function AccountActivationAlert() {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
const handleOnClick = () => {
setShowSpinner(true);
setShowCheck(false);
sendActivationEmail().then(() => {
setShowSpinner(false);
setShowCheck(true);
});
};
const showAccountActivationAlert = Cookies.get('show-account-activation-popup');
if (showAccountActivationAlert !== undefined) {
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
// of cookie would make it infinit rendering
if (Cookies.get('show-account-activation-popup') === undefined) {
setShowModal(true);
}
}
const title = (
<h3>
<FormattedMessage
id="account-activation.alert.title"
defaultMessage="Activate your account so you can log back in"
description="Title for account activation alert which is shown after the registration"
/>
</h3>
);
const button = (
<Button
variant="primary"
className=""
onClick={() => setShowModal(false)}
>
<FormattedMessage
id="account-activation.alert.button"
defaultMessage="Continue to {siteName}"
description="account activation alert continue button"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
<Icon src={ArrowForward} className="ml-1 d-inline-block align-bottom" />
</Button>
);
const children = () => {
let bodyContent = null;
const message = (
<FormattedMessage
id="account-activation.alert.message"
defaultMessage="We sent an email to {boldEmail} with a link to activate your account. Cant find it? Check your spam folder or
{sendEmailTag}."
description="Message for account activation alert which is shown after the registration"
values={{
boldEmail: <b>{getAuthenticatedUser() && getAuthenticatedUser().email}</b>,
sendEmailTag: (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a href="#" role="button" onClick={handleOnClick}>
<FormattedMessage
id="account-activation.resend.link"
defaultMessage="resend the email"
description="Message for resend link in account activation alert which is shown after the registration"
/>
</a>
),
}}
/>
);
bodyContent = (
<div>
{message}
</div>
);
if (!showCheck && showSpinner) {
bodyContent = (
<div>
{message}
<Spinner
animation="border"
variant="secondary"
style={{ height: '1.5rem', width: '1.5rem' }}
/>
</div>
);
}
if (showCheck && !showSpinner) {
bodyContent = (
<div>
{message}
<Icon
src={Check}
style={{ height: '1.7rem', width: '1.25rem' }}
className="text-success-500 d-inline-block position-fixed"
/>
</div>
);
}
return bodyContent;
};
return (
<AlertModal
isOpen={showModal}
title={title}
footerNode={button}
onClose={() => ({})}
>
{children()}
</AlertModal>
);
}
export default injectIntl(AccountActivationAlert);

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,9 @@ function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username
);
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
dashboardMenuItem = (
<Dropdown.Item href={enterpriseLearnerPortalLink.href}>
<Dropdown.Item
href={enterpriseLearnerPortalLink.href}
>
{enterpriseLearnerPortalLink.content}
</Dropdown.Item>
);
@@ -60,17 +62,13 @@ function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username
}
AuthenticatedUserDropdown.propTypes = {
enterpriseLearnerPortalLink: PropTypes.string,
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
enterpriseLearnerPortalLink: PropTypes.shape({
type: PropTypes.string,
href: PropTypes.string,
content: PropTypes.string,
}),
};
AuthenticatedUserDropdown.defaultProps = {
enterpriseLearnerPortalLink: undefined,
enterpriseLearnerPortalLink: '',
};
export default injectIntl(AuthenticatedUserDropdown);

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils';
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
@@ -29,7 +29,7 @@ LinkedLogo.propTypes = {
};
function Header({
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
courseOrg, courseNumber, courseTitle, intl,
}) {
const { authenticatedUser } = useContext(AppContext);
@@ -61,19 +61,19 @@ function Header({
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">
<div className="container-fluid py-2 d-flex align-items-center">
{headerLogo}
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
{showUserDropdown && authenticatedUser && (
{authenticatedUser && (
<AuthenticatedUserDropdown
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
{!authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
@@ -86,14 +86,12 @@ Header.propTypes = {
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

@@ -10,14 +10,4 @@ Factory.define('courseHomeMetadata')
is_self_paced: false,
is_enrolled: false,
can_load_courseware: false,
course_access: {
additional_context_user_message: null,
developer_message: null,
error_code: null,
has_access: true,
user_fragment: null,
user_message: null,
},
start: '2013-02-05T05:00:00Z',
user_timezone: 'UTC',
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('upgradeNotificationData')
.option('host', 'http://localhost:18000')
.option('dateBlocks', [])
.option('offer', null)
.option('userTimezone', null)
.option('accessExpiration', null)
.option('contentTypeGatingEnabled', false)
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
.attr('upsellPageName', 'test')
.attr('verifiedMode', ['host'], (host) => ({
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currencySymbol: '$',
price: 149,
sku: 'ABCD1234',
upgradeUrl: `${host}/dashboard`,
}))
.attr('org', 'edX')
.attr('timeOffsetMillis', 0);

View File

@@ -5,7 +5,6 @@ Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
@@ -20,23 +19,13 @@ Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
@@ -70,12 +59,6 @@ Object {
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object {
"currencySymbol": "$",
"price": 10,
"upgradeUrl": "test",
},
},
},
"dates": Object {
@@ -295,6 +278,7 @@ Object {
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true,
"userTimezone": "America/New_York",
},
},
},
@@ -309,7 +293,6 @@ Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
@@ -324,23 +307,13 @@ Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
@@ -374,28 +347,17 @@ Object {
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object {
"currencySymbol": "$",
"price": 10,
"upgradeUrl": "test",
},
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"canShowUpgradeSock": false,
"certData": Object {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
"downloadUrl": null,
},
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false,
"effortActivities": undefined,
"effortTime": undefined,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
@@ -407,6 +369,8 @@ Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"effortActivities": 2,
"effortTime": 15,
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
@@ -420,8 +384,8 @@ Object {
"complete": false,
"description": null,
"due": null,
"effortActivities": 2,
"effortTime": 15,
"effortActivities": undefined,
"effortTime": undefined,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
@@ -441,6 +405,11 @@ Object {
"title": "Bookmarks",
"url": "https://example.com/bookmarks",
},
Object {
"analyticsId": "edx.tool.verified_upgrade",
"title": "Upgrade to Verified",
"url": "https://example.com/upgrade",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
@@ -449,23 +418,20 @@ Object {
},
"datesWidget": Object {
"courseDateBlocks": Array [],
"userTimezone": "UTC",
},
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"offer": null,
"resumeCourse": Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"timeOffsetMillis": 0,
"userHasPassingGrade": undefined,
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
@@ -483,191 +449,3 @@ Object {
},
}
`;
exports[`Data layer integration tests Test fetchProgressTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"slug": "outline",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
Object {
"slug": "dates",
"title": "Dates",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
},
],
"title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object {
"currencySymbol": "$",
"price": 10,
"upgradeUrl": "test",
},
},
},
"progress": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"certificateData": Object {},
"completionSummary": Object {
"completeCount": 1,
"incompleteCount": 1,
"lockedCount": 0,
},
"courseGrade": Object {
"isPassing": true,
"letterGrade": "pass",
"percent": 1,
"visiblePercent": 1,
},
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": Object {
"assignmentPolicies": Array [
Object {
"averageGrade": 1,
"numDroppable": 1,
"shortLabel": "HW",
"type": "Homework",
"weight": 1,
"weightedGrade": 1,
},
],
"gradeRange": Object {
"pass": 0.75,
},
},
"hasScheduledContent": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionScores": Array [
Object {
"displayName": "First section",
"subsections": Array [
Object {
"assignmentType": "Homework",
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection",
"hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0,
"numPointsPossible": 3,
"percentGraded": 0,
"problemScores": Array [
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
},
],
},
Object {
"displayName": "Second section",
"subsections": Array [
Object {
"assignmentType": "Homework",
"displayName": "Second subsection",
"hasGradedAssignment": true,
"numPointsEarned": 1,
"numPointsPossible": 1,
"percentGraded": 1,
"problemScores": Array [
Object {
"earned": 1,
"possible": 1,
},
],
"showCorrectness": "always",
"showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",
},
],
},
],
"studioUrl": "http://studio.edx.org/settings/grading/course-v1:edX+Test+run",
"userHasPassingGrade": false,
"verificationData": Object {
"link": null,
"status": "none",
"statusDate": null,
},
"verifiedMode": null,
},
},
},
"recommendations": Object {
"recommendationsStatus": "loading",
},
}
`;

View File

@@ -3,90 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils';
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
let dropCount = numDroppable;
// Drop the lowest grades
while (dropCount && points.length >= dropCount) {
const lowestScore = Math.min(...points);
const lowestScoreIndex = points.indexOf(lowestScore);
points.splice(lowestScoreIndex, 1);
dropCount--;
}
let averageGrade = 0;
let weightedGrade = 0;
if (points.length) {
averageGrade = points.reduce((a, b) => a + b, 0) / points.length;
weightedGrade = averageGrade * assignmentWeight;
}
return { averageGrade, weightedGrade };
};
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
const gradeByAssignmentType = {};
assignmentPolicies.forEach(assignment => {
// Create an array with the number of total assignments and set the scores to 0
// as placeholders for assignments that have not yet been released
gradeByAssignmentType[assignment.type] = {
grades: Array(assignment.numTotal).fill(0),
numAssignmentsCreated: 0,
numTotalExpectedAssignments: assignment.numTotal,
};
});
sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
return;
}
const {
assignmentType,
numPointsEarned,
numPointsPossible,
} = subsection;
// If a subsection's assignment type does not match an assignment policy in Studio,
// we won't be able to include it in this accumulation of grades by assignment type.
// This may happen if a course author has removed/renamed an assignment policy in Studio and
// neglected to update the subsection's of that assignment type
if (!gradeByAssignmentType[assignmentType]) {
return;
}
let {
numAssignmentsCreated,
} = gradeByAssignmentType[assignmentType];
numAssignmentsCreated++;
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
// of expected assignments
gradeByAssignmentType[assignmentType].grades.shift();
}
// Add the graded assignment to the list
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
// Record the created assignment
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
});
});
return assignmentPolicies.map((assignment) => {
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
gradeByAssignmentType[assignment.type].grades,
assignment.weight,
assignment.numDroppable,
);
return {
averageGrade,
numDroppable: assignment.numDroppable,
shortLabel: assignment.shortLabel,
type: assignment.type,
weight: assignment.weight,
weightedGrade,
};
});
}
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
@@ -98,7 +14,6 @@ function normalizeCourseHomeCourseMetadata(metadata) {
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
@@ -112,16 +27,19 @@ export function normalizeOutlineBlocks(courseId, blocks) {
switch (block.type) {
case 'course':
models.courses[block.id] = {
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: courseId,
title: block.display_name,
sectionIds: block.children || [],
hasScheduledContent: block.has_scheduled_content,
};
break;
case 'chapter':
models.sections[block.id] = {
complete: block.complete,
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: block.id,
title: block.display_name,
resumeBlock: block.resume_block,
@@ -180,7 +98,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
}
export async function getCourseHomeCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data);
@@ -192,7 +110,7 @@ export async function getCourseHomeCourseMetadata(courseId) {
// import './__factories__';
export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
@@ -203,85 +121,29 @@ export async function getDatesTabData(courseId) {
return {};
}
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
}
throw error;
}
}
export async function getProgressTabData(courseId, targetUserId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`;
// If targetUserId is passed in, we will get the progress page data
// for the user with the provided id, rather than the requesting user.
if (targetUserId) {
url += `/${targetUserId}/`;
}
export async function getProgressTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data);
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
camelCasedData.gradingPolicy.assignmentPolicies,
camelCasedData.sectionScores,
);
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
// assignmentPolicies have been filtered by what's visible to the learner.
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
) : camelCasedData.courseGrade.percent;
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
>= Math.min(...Object.values(data.grading_policy.grade_range));
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
// in order to preserve a course team's desired grade formatting.
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range;
camelCasedData.gradesFeatureIsFullyLocked = camelCasedData.completionSummary.lockedCount > 0;
camelCasedData.gradesFeatureIsPartiallyLocked = false;
if (camelCasedData.gradesFeatureIsFullyLocked) {
camelCasedData.sectionScores.forEach((chapter) => {
chapter.subsections.forEach((subsection) => {
// If something is eligible to be gated by content type gating and would show up on the progress page
if (subsection.assignmentType !== null && subsection.hasGradedAssignment && subsection.showGrades
&& (subsection.numPointsPossible > 0 || subsection.numPointsEarned > 0)) {
// but the learner still has access to it, then we are in a partially locked, rather than fully locked state
// since the learner has access to some (but not all) content that would normally be locked
if (subsection.learnerHasAccess) {
camelCasedData.gradesFeatureIsPartiallyLocked = true;
camelCasedData.gradesFeatureIsFullyLocked = false;
}
}
});
});
}
return camelCasedData;
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
}
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
throw error;
}
}
export async function getProctoringInfoData(courseId, username) {
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
if (username) {
url += `&username=${encodeURIComponent(username)}`;
}
@@ -297,30 +159,11 @@ export async function getProctoringInfoData(courseId, username) {
}
}
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
let timeOffsetMillis = 0;
if (headerDate !== undefined) {
const headerTime = Date.parse(headerDate);
const roundTripMillis = requestTime - responseTime;
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
timeOffsetMillis = headerTime - localTime;
}
return timeOffsetMillis;
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
let requestTime = Date.now();
let responseTime = requestTime;
try {
requestTime = Date.now();
tabData = await getAuthenticatedHttpClient().get(url);
responseTime = Date.now();
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
@@ -332,47 +175,35 @@ export async function getOutlineTabData(courseId) {
const {
data,
headers,
} = tabData;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const certData = camelCaseObject(data.cert_data);
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseGoals = camelCaseObject(data.course_goals);
const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget);
const enrollAlert = camelCaseObject(data.enroll_alert);
const enrollmentMode = data.enrollment_mode;
const handoutsHtml = data.handouts_html;
const hasScheduledContent = data.has_scheduled_content;
const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer);
const resumeCourse = camelCaseObject(data.resume_course);
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
return {
accessExpiration,
canShowUpgradeSock,
certData,
courseBlocks,
courseGoals,
courseTools,
datesBannerInfo,
datesWidget,
enrollAlert,
enrollmentMode,
handoutsHtml,
hasScheduledContent,
hasEnded,
offer,
resumeCourse,
timeOffsetMillis, // This should move to a global time correction reference
userHasPassingGrade,
verifiedMode,
welcomeMessageHtml,
};
@@ -387,12 +218,12 @@ export async function postCourseDeadlines(courseId, model) {
}
export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`);
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
}
export async function postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`);
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
}
@@ -408,9 +239,3 @@ export async function executePostFromPostEvent(postData, researchEventData) {
research_event_data: researchEventData,
});
}
export async function unsubscribeFromCourseGoal(token) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'course-home',
@@ -16,23 +15,18 @@ const slice = createSlice({
toastHeader: '',
},
reducers: {
fetchTabDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.targetUserId = payload.targetUserId;
state.courseStatus = LOADED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
setCallToActionToast: (state, { payload }) => {
const {
header,
@@ -47,10 +41,9 @@ const slice = createSlice({
});
export const {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
setCallToActionToast,
} = slice.actions;

View File

@@ -17,7 +17,6 @@ import {
} from '../../generic/model-store';
import {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
@@ -28,12 +27,12 @@ const eventTypes = {
POST_EVENT: 'post_event',
};
export function fetchTab(courseId, tab, getTabData, targetUserId) {
export function fetchTab(courseId, tab, getTabData) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId),
getTabData(courseId, targetUserId),
getTabData(courseId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
@@ -62,11 +61,8 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
logError(tabDataResult.reason);
}
// Disable the access-denied path for now - it caused a regression
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId }));
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
@@ -78,8 +74,8 @@ export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData);
}
export function fetchProgressTab(courseId, targetUserId) {
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId);
export function fetchProgressTab(courseId) {
return fetchTab(courseId, 'progress', getProgressTabData);
}
export function fetchOutlineTab(courseId) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,17 +4,14 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './timeline/Timeline';
import Timeline from './Timeline';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store';
/** [MM-P2P] Experiment */
import { initDatesMMP2P } from '../../experiments/mm-p2p';
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
function DatesTab({ intl }) {
const {
@@ -22,19 +19,18 @@ function DatesTab({ intl }) {
} = useSelector(state => state.courseHome);
const {
isSelfPaced,
org,
} = useModel('courseHomeMeta', courseId);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel('dates', courseId);
/** [MM-P2P] Experiment */
const mmp2p = initDatesMMP2P(courseId);
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const logUpgradeLinkClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
@@ -52,14 +48,16 @@ function DatesTab({ intl }) {
{intl.formatMessage(messages.title)}
</div>
{ /** [MM-P2P] Experiment */ }
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
<>
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
<SuggestedScheduleHeader />
<UpgradeToCompleteAlert logUpgradeLinkClick={logUpgradeLinkClick} />
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
</>
)}
{ !mmp2p.state.isEnabled && (
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
logUpgradeLinkClick={logUpgradeLinkClick}
model="dates"
tabFetch={fetchDatesTab}
/>
) }
<Timeline mmp2p={mmp2p} />
</>
);

View File

@@ -23,37 +23,19 @@ jest.mock('@edx/frontend-platform/analytics');
describe('DatesTab', () => {
let axiosMock;
let store;
let component;
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
});
const datesTabData = Factory.build('datesTabData');
let courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
const { id: courseId } = courseMetadata;
const datesUrl = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {
courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
const store = initializeStore();
const component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
@@ -79,8 +61,15 @@ describe('DatesTab', () => {
describe('when receiving a full set of dates data', () => {
beforeEach(() => {
const datesTabData = Factory.build('datesTabData');
const courseMetadata = Factory.build('courseHomeMetadata');
const { id: courseId } = courseMetadata;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
render(component);
@@ -144,26 +133,34 @@ describe('DatesTab', () => {
});
});
describe('Suggested schedule messaging', () => {
describe('Dates banner container ', () => {
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
const { id: courseId } = courseMetadata;
const datesTabData = Factory.build('datesTabData');
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
beforeEach(() => {
setMetadata({ is_self_paced: true, is_enrolled: true });
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
history.push(`/course/${courseId}/dates`);
});
it('renders SuggestedScheduleHeader', async () => {
it('renders datesTabInfoBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: false,
missedDeadlines: false,
missedGatedContent: false,
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('Weve built a suggested schedule to help you stay on track. But dont worry—its flexible so you can learn at your own pace.')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText("We've built a suggested schedule to help you stay on track.")).toBeInTheDocument());
});
it('renders UpgradeToCompleteAlert', async () => {
it('renders upgradeToCompleteGradedBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: false,
@@ -171,14 +168,15 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
expect(screen.getByText('which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
});
it('renders UpgradeToShiftDatesAlert', async () => {
it('renders upgradeToResetBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
@@ -186,15 +184,15 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
});
it('renders ShiftDatesAlert', async () => {
it('renders resetDatesBanner', async () => {
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
missedDeadlines: true,
@@ -202,7 +200,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
@@ -218,7 +216,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
// confirm "Shift due dates" button has rendered
@@ -246,7 +244,7 @@ describe('DatesTab', () => {
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
});
it('sends analytics event onClick of upgrade button in UpgradeToCompleteAlert', async () => {
it('sends analytics event onClick of upgrade button in upgradeToCompleteGradedBanner', async () => {
sendTrackEvent.mockClear();
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
@@ -255,7 +253,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade now' }));
@@ -272,7 +270,7 @@ describe('DatesTab', () => {
});
});
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
it('sends analytics event onClick of upgrade button in upgradeToResetBanner', async () => {
sendTrackEvent.mockClear();
datesTabData.datesBannerInfo = {
contentTypeGatingEnabled: true,
@@ -281,7 +279,7 @@ describe('DatesTab', () => {
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
};
axiosMock.onGet(datesUrl).reply(200, datesTabData);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
render(component);
const upgradeButton = await waitFor(() => screen.getByRole('button', { name: 'Upgrade to shift due dates' }));
@@ -298,55 +296,4 @@ describe('DatesTab', () => {
});
});
});
describe('when receiving an access denied error', () => {
// These tests could go into any particular tab, as they all go through the same flow. But dates tab works.
async function renderDenied(errorCode) {
setMetadata({
course_access: {
has_access: false,
error_code: errorCode,
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
},
});
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
}
beforeEach(() => {
axiosMock.onGet(datesUrl).reply(200, datesTabData);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
});
it('redirects to course survey for a survey_required error code', async () => {
await renderDenied('survey_required');
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
});
it('redirects to dashboard for an unfulfilled_milestones error code', async () => {
await renderDenied('unfulfilled_milestones');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
});
it('redirects to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
await renderDenied('audit_expired');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
});
it('redirects to the dashboard with a notlive start date for a course_not_started error code', async () => {
await renderDenied('course_not_started');
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?notlive=2/5/2013'); // date from factory
});
it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
});
});

View File

@@ -2,20 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
FormattedDate,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tooltip, OverlayTrigger } from '@edx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useModel } from '../../../generic/model-store';
import { useModel } from '../../generic/model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from '../utils';
import { isLearnerAssignment } from './utils';
function Day({
date,
@@ -29,10 +24,10 @@ function Day({
const {
courseId,
} = useSelector(state => state.courseHome);
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
} = useModel('dates', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
@@ -55,16 +50,18 @@ function Day({
{/* Content */}
<div className="d-inline-block ml-3 pl-2">
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
<FormattedDate
/** [MM-P2P] Experiment */
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
<div className="mb-1" data-testid="dates-header">
<p className="d-inline text-dark-500 font-weight-bold">
<FormattedDate
/** [MM-P2P] Experiment */
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</p>
{badges}
</div>
{items.map((item) => {
@@ -73,27 +70,16 @@ function Day({
? getBadgeListAndColor(new Date(mmp2p.state.upgradeDeadline), intl, item, items)
: getBadgeListAndColor(date, intl, item, items);
const showDueDateTime = item.dateType === 'assignment-due-date';
const showLink = item.link && isLearnerAssignment(item);
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-primary-700' : 'text-gray-500';
const textColor = available ? 'text-dark-500' : 'text-dark-200';
return (
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item">
<div key={item.title + item.date} className={textColor} data-testid="dates-item">
<div>
<span className="small">
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>
{showDueDateTime && (
<span>
<span className="mx-1">due</span>
<FormattedTime
value={date}
timeZoneName="short"
{...timezoneFormatArgs}
/>
</span>
)}
<span className="font-weight-bold small mt-1">
{item.assignmentType && `${item.assignmentType}: `}{title}
</span>
{itemBadges}
{item.extraInfo && (

View File

@@ -3,10 +3,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useModel } from '../../../generic/model-store';
import { useModel } from '../../generic/model-store';
import Day from './Day';
import { daycmp, isLearnerAssignment } from '../utils';
import { daycmp, isLearnerAssignment } from './utils';
/** [MM-P2P] Experiment (argument) */
export default function Timeline({ mmp2p }) {
@@ -64,7 +64,7 @@ export default function Timeline({ mmp2p }) {
}
return (
<ul className="list-unstyled m-0 mt-4 pt-2">
<ul className="list-unstyled m-0">
{groupedDates.map((groupedDate) => (
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
))}

View File

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

View File

@@ -1,52 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Header } from '../../course-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState({});
// We don't need to bother with redux for this simple page. We're not sharing state with other pages at all.
useEffect(() => {
unsubscribeFromCourseGoal(token)
.then(
(result) => {
setIsLoading(false);
setData(result.data);
},
() => {
setIsLoading(false);
setError(true);
},
);
}, []); // deps=[] to only run once
return (
<>
<Header showUserDropdown={false} />
<main id="main-content" className="container my-5 text-center">
{isLoading && (
<PageLoading srMessage={`${intl.formatMessage(messages.loading)}`} />
)}
{!isLoading && (
<ResultPage error={error} courseTitle={data.courseTitle} />
)}
</main>
</>
);
}
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(GoalUnsubscribe);

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
contactSupport: {
id: 'learning.goals.unsubscribe.contact',
defaultMessage: 'contact support',
},
description: {
id: 'learning.goals.unsubscribe.description',
defaultMessage: 'You will no longer receive email reminders about your goal for {courseTitle}.',
},
errorHeader: {
id: 'learning.goals.unsubscribe.errorHeader',
defaultMessage: 'Something went wrong',
},
goToDashboard: {
id: 'learning.goals.unsubscribe.goToDashboard',
defaultMessage: 'Go to dashboard',
},
header: {
id: 'learning.goals.unsubscribe.header',
defaultMessage: 'Youve unsubscribed from goal reminders',
},
loading: {
id: 'learning.goals.unsubscribe.loading',
defaultMessage: 'Unsubscribing…',
},
});
export default messages;

View File

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

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Toast } from '@edx/paragon';
@@ -10,24 +9,24 @@ import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts';
import CourseSock from '../../generic/course-sock';
import CourseTools from './widgets/CourseTools';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchOutlineTab } from '../data';
import genericMessages from '../../generic/messages';
import messages from './messages';
import Section from './Section';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import UpgradeCard from './widgets/UpgradeCard';
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
import useCourseEndAlert from './alerts/course-end-alert';
import useCourseStartAlert from '../../alerts/course-start-alert';
import useCourseStartAlert from './alerts/course-start-alert';
import useOfferAlert from '../../alerts/offer-alert';
import usePrivateCourseAlert from './alerts/private-course-alert';
import useScheduledContentAlert from './alerts/scheduled-content-alert';
import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
/** [MM-P2P] Experiment */
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
@@ -38,15 +37,14 @@ function OutlineTab({ intl }) {
} = useSelector(state => state.courseHome);
const {
isSelfPaced,
org,
title,
username,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
accessExpiration,
canShowUpgradeSock,
courseBlocks: {
courses,
sections,
@@ -54,17 +52,18 @@ function OutlineTab({ intl }) {
courseGoals: {
goalOptions,
selectedGoal,
} = {},
},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
userTimezone,
},
hasEnded,
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
offer,
timeOffsetMillis,
verifiedMode,
} = useModel('outline', courseId);
@@ -86,17 +85,18 @@ function OutlineTab({ intl }) {
};
// Below the course title alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(courseId, offer, org, userTimezone, 'outline-course-alerts', 'course_home');
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, 'outline-course-alerts', 'course_home');
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const privateCourseAlert = usePrivateCourseAlert(courseId);
const scheduledContentAlert = useScheduledContentAlert(courseId);
const rootCourseId = courses && Object.keys(courses)[0];
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const courseSock = useRef(null);
const logUpgradeToShiftDatesLinkClick = () => {
const logUpgradeLinkClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: 'personalized_learner_schedules',
@@ -106,19 +106,9 @@ function OutlineTab({ intl }) {
});
};
const isEnterpriseUser = () => {
const authenticatedUser = getAuthenticatedUser();
const userRoleNames = authenticatedUser ? authenticatedUser.roles.map(role => role.split(':')[0]) : [];
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
return (
<>
<Toast
@@ -128,13 +118,13 @@ function OutlineTab({ intl }) {
>
{goalToastHeader}
</Toast>
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
<div className="row w-100 m-0 mb-3 justify-content-between">
<div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
{resumeCourseUrl && (
<div className="col-12 col-sm-auto p-0">
<Button variant="brand" block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
</div>
@@ -142,7 +132,6 @@ function OutlineTab({ intl }) {
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
<AccountActivationAlert />
<div className="col-12">
<AlertList
topic="outline-private-alerts"
@@ -159,18 +148,25 @@ function OutlineTab({ intl }) {
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...accessExpirationAlert,
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
...offerAlert,
}}
/>
)}
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
<>
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
</>
{courseDateBlocks && (
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
logUpgradeLinkClick={logUpgradeLinkClick}
model="outline"
tabFetch={fetchOutlineTab}
/** [MM-P2P] Experiment */
isMMP2PEnabled={MMP2P.state.isEnabled}
/>
)}
{!courseGoalToDisplay && goalOptions && goalOptions.length > 0 && (
<CourseGoalCard
@@ -227,17 +223,11 @@ function OutlineTab({ intl }) {
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
<UpgradeCard
courseId={courseId}
org={org}
onLearnMore={
canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null
}
/>
)}
<CourseDates
@@ -251,6 +241,16 @@ function OutlineTab({ intl }) {
</div>
)}
</div>
{canShowUpgradeSock && (
<CourseSock
courseId={courseId}
offer={offer}
orgKey={org}
pageLocation="Home Page"
ref={courseSock}
verifiedMode={verifiedMode}
/>
)}
</>
);
}

View File

@@ -4,7 +4,6 @@ import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import Cookies from 'js-cookie';
import userEvent from '@testing-library/user-event';
import { buildMinimalCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
@@ -14,9 +13,7 @@ import {
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -25,13 +22,12 @@ describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
@@ -59,7 +55,6 @@ describe('Outline Tab', () => {
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'created',
@@ -163,9 +158,9 @@ describe('Outline Tab', () => {
});
});
describe('Suggested schedule alerts', () => {
describe('Dates Banner', () => {
beforeEach(() => {
setMetadata({ is_enrolled: true, is_self_paced: true });
setMetadata({ is_enrolled: true });
setTabData({
dates_banner_info: {
content_type_gating_enabled: true,
@@ -188,15 +183,15 @@ describe('Outline Tab', () => {
});
});
it('renders UpgradeToShiftDatesAlert', async () => {
it('renders upgradeToReset', async () => {
await fetchAndRender();
expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument();
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument();
expect(screen.getByText('You are auditing this course,')).toBeInTheDocument();
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
});
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => {
it('sends analytics event onClick of upgrade button in banner', async () => {
await fetchAndRender();
sendTrackEvent.mockClear();
@@ -286,13 +281,13 @@ describe('Outline Tab', () => {
],
});
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Important dates' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Upcoming Dates' })).toBeInTheDocument();
});
it('does not render when course date blocks are not populated', async () => {
setMetadata({ is_enrolled: true });
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Important dates' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Upcoming Dates' })).not.toBeInTheDocument();
});
it('sends analytics event onClick of upgrade link', async () => {
@@ -440,6 +435,35 @@ describe('Outline Tab', () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument();
});
it('analytics sent when upgrade link clicked', async () => {
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.tool.accessed', {
org_key: 'edX',
courserun_key: courseId,
course_id: courseId,
is_staff: false,
tool_name: 'edx.tool.verified_upgrade',
});
});
});
describe('Alert List', () => {
@@ -459,8 +483,8 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert');
expect(alert).toHaveAttribute('role', 'alert');
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
@@ -469,8 +493,8 @@ describe('Outline Tab', () => {
it('displays alert for unenrolled user', async () => {
await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert');
expect(alert).toHaveAttribute('role', 'alert');
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
});
@@ -495,35 +519,70 @@ describe('Outline Tab', () => {
});
describe('Access Expiration Alert', () => {
it('renders page banner on masquerade', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
it('has special masquerade text', async () => {
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
},
});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
await fetchAndRender();
await screen.findByText('This learner does not have access to this course.', { exact: false });
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
it('shows expiration', async () => {
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
},
});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store }));
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
await fetchAndRender();
await screen.findByText('Audit Access Expires');
});
it('shows upgrade prompt', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
});
it('sends analytics event onClick of upgrade link', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
sendTrackEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'FBE_banner',
linkName: 'course_home_audit_access_expires',
linkType: 'link',
pageName: 'course_home',
});
});
});
@@ -532,7 +591,16 @@ describe('Outline Tab', () => {
it('appears several days out', async () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true, start: '2999-01-01T00:00:00Z' });
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
@@ -541,7 +609,16 @@ describe('Outline Tab', () => {
it('appears today', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true, start: startDate });
setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
@@ -594,351 +671,70 @@ describe('Outline Tab', () => {
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({}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
});
await fetchAndRender();
await screen.findByText('We are working on generating course certificates.');
});
});
describe('Offer Alert', () => {
it('sends analytics event onClick of upgrade link', async () => {
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
cert_web_view_url: null,
certificate_available_date: tomorrow.toISOString(),
download_url: null,
offer: {
code: 'EDXWELCOME',
expiration_date: '2070-01-01T12:00:00Z',
original_price: '$100',
discounted_price: '$85',
percentage: 15,
upgrade_url: 'https://example.com/upgrade',
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
});
it('renders verification alert', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
it('sends analytics event onClick of upgrade link', async () => {
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
offer: {
code: 'EDXWELCOME',
expiration_date: '2070-01-01T12:00:00Z',
original_price: '$100',
discounted_price: '$85',
percentage: 15,
upgrade_url: 'https://example.com/upgrade',
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
});
it('renders non passing grade', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {},
user_has_passing_grade: false,
has_ended: true,
enrollment_mode: 'verified',
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
{
date_type: 'verification-deadline-date',
date: tomorrow.toISOString(),
link_text: 'Verify',
title: 'Verification Upgrade Deadline',
},
],
});
await fetchAndRender();
screen.getAllByText('You are not eligible for a certificate');
expect(screen.queryByText('You are not eligible for a certificate')).toBeInTheDocument();
});
it('tracks request cert button', async () => {
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
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.REQUESTING,
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: 'Request certificate' });
fireEvent.click(requestingButton);
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
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',
},
],
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'welcome',
linkName: 'course_home_welcome',
linkType: 'link',
pageName: 'course_home',
});
await fetchAndRender();
sendTrackEvent.mockClear();
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
{
courserun_key: 'course-v1:edX+Test+run',
is_staff: false,
org_key: 'edX',
});
});
it('tracks unverified cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
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('link', { name: 'Verify my ID' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: 'course-v1:edX+Test+run',
is_staff: false,
org_key: 'edX',
});
});
});
describe('Scheduled Content Alert', () => {
it('appears correctly', async () => {
const now = new Date();
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { hasScheduledContent: true });
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
date_blocks: [
{
date_type: 'course-end-date',
date: tomorrow.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('More content is coming soon!')).toBeInTheDocument();
});
});
describe('Scheduled Content Alert not present without courseBlocks', () => {
it('appears correctly', async () => {
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({
course_blocks: null,
date_blocks: [
{
date_type: 'course-end-date',
date: tomorrow.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
expect(screen.queryByText('More content is coming soon!')).not.toBeInTheDocument();
});
});
});
describe('Certificate (web) Complete Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: 'certificate/testuuid',
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
});
});
describe('Requesting Certificate Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
expect(screen.queryByText('Request certificate')).toBeInTheDocument();
});
});
describe('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();
});
});
@@ -1019,7 +815,7 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding exam has been approved in another course.')).toBeInTheDocument();
expect(screen.queryByText('You are eligible to take proctored exams in this course.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).not.toBeInTheDocument();
});
@@ -1035,7 +831,7 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
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('Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
@@ -1168,51 +964,4 @@ describe('Outline Tab', () => {
});
});
});
describe('Accont Activation Alert', () => {
beforeEach(() => {
const intersectionObserverMock = () => ({
observe: () => null,
disconnect: () => null,
});
window.IntersectionObserver = jest.fn().mockImplementation(intersectionObserverMock);
});
it('displays account activation alert if cookie is set true', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
expect(screen.queryByText('Activate your account so you can log back in')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'resend the email' })).toBeInTheDocument();
});
it('do not displays account activation alert if cookie is not set true', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn();
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
expect(screen.queryByText('Activate your account so you can log back in')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'resend the email' })).not.toBeInTheDocument();
});
it('sends account activation email on clicking the resened email in account activation alert', async () => {
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => 'true');
Cookies.remove = jest.fn().mockImplementation(() => { Cookies.get = jest.fn(); });
await fetchAndRender();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const resendEmailUrl = `${getConfig().LMS_BASE_URL}/api/send_account_activation_email`;
axiosMock.onPost(resendEmailUrl).reply(200, {});
const resendLink = screen.getByRole('button', { name: 'resend the email' });
fireEvent.click(resendLink);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].url).toEqual(resendEmailUrl);
});
});
});

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, FormattedRelative } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
function CertificateAvailableAlert({ payload }) {
const {
certDate,
username,
userTimezone,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.cert.title"
defaultMessage="We are working on generating course certificates."
/>
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.cert.when"
defaultMessage="If you have earned a certificate, you will be able to access it {timeRemaining}. You will also be able to view your certificates on your {profileLink}."
values={{
profileLink: (
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
>
<FormattedMessage
id="learning.outline.alert.cert.profile"
defaultMessage="Learner Profile"
/>
</Hyperlink>
),
timeRemaining: (
<FormattedRelative
key="timeRemaining"
value={certDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
CertificateAvailableAlert.propTypes = {
payload: PropTypes.shape({
certDate: PropTypes.string,
username: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};
export default CertificateAvailableAlert;

View File

@@ -0,0 +1,44 @@
import React, { useMemo } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailableAlert'));
function useCertificateAvailableAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const authenticatedUser = getAuthenticatedUser();
const username = authenticatedUser ? authenticatedUser.username : '';
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const endDate = endBlock ? new Date(endBlock.date) : null;
const hasEnded = endBlock ? endDate < new Date() : false;
const isVisible = isEnrolled && certBlock && hasEnded; // only show if we're between end and cert dates
const payload = {
certDate: certBlock && certBlock.date,
username,
userTimezone,
};
useAlert(isVisible, {
code: 'clientCertificateAvailableAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCertificateAvailableAlert: CertificateAvailableAlert,
};
}
export default useCertificateAvailableAlert;

View File

@@ -1,219 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@edx/paragon';
import { useDispatch } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheckCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import certMessages from './messages';
import certStatusMessages from '../../../progress-tab/certificate-status/messages';
import { requestCert } from '../../../data/thunks';
export const CERT_STATUS_TYPE = {
EARNED_NOT_AVAILABLE: 'earned_but_not_available',
DOWNLOADABLE: 'downloadable',
REQUESTING: 'requesting',
UNVERIFIED: 'unverified',
};
function CertificateStatusAlert({ intl, payload }) {
const dispatch = useDispatch();
const {
certificateAvailableDate,
certStatus,
courseEndDate,
courseId,
certURL,
isWebCert,
userTimezone,
org,
notPassingCourseEnded,
tabs,
} = payload;
// eslint-disable-next-line react/prop-types
const AlertWrapper = (props) => props.children(props);
const sendAlertClickTracking = (id) => {
const { administrator } = getAuthenticatedUser();
sendTrackEvent(id, {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
});
};
const renderCertAwardedStatus = () => {
const alertProps = {
variant: 'success',
icon: faCheckCircle,
iconClassName: 'alert-icon text-success-500',
};
if (certStatus === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const certificateAvailableDateFormatted = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
const courseEndDateFormatted = <FormattedDate value={courseEndDate} day="numeric" month="long" year="numeric" />;
alertProps.header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
alertProps.body = (
<p>
<FormattedMessage
id="learning.outline.alert.cert.when"
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
scheduled to be available after {certificateAvailableDate}."
values={{
courseEndDateFormatted,
certificateAvailableDate: certificateAvailableDateFormatted,
}}
{...timezoneFormatArgs}
/>
</p>
);
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
if (isWebCert) {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
} else {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
}
alertProps.buttonVisible = true;
alertProps.buttonLink = certURL;
alertProps.buttonAction = () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked');
};
} else if (certStatus === CERT_STATUS_TYPE.REQUESTING) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.requestableButton);
alertProps.buttonVisible = true;
alertProps.buttonLink = '';
alertProps.buttonAction = () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked');
dispatch(requestCert(courseId));
};
}
return alertProps;
};
const renderNotIDVerifiedStatus = () => {
const alertProps = {
variant: 'warning',
icon: faExclamationTriangle,
iconClassName: 'alert-icon text-warning-500',
header: intl.formatMessage(certStatusMessages.unverifiedHomeHeader),
buttonMessage: intl.formatMessage(certStatusMessages.unverifiedHomeButton),
body: intl.formatMessage(certStatusMessages.unverifiedHomeBody),
buttonVisible: true,
buttonLink: getConfig().SUPPORT_URL_ID_VERIFICATION,
buttonAction: () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked');
},
};
return alertProps;
};
const renderNotPassingCourseEnded = () => {
const progressTab = tabs.find(tab => tab.slug === 'progress');
const progressLink = progressTab && progressTab.url;
const alertProps = {
header: intl.formatMessage(certMessages.certStatusNotPassingHeader),
buttonMessage: intl.formatMessage(certMessages.certStatusNotPassingButton),
body: intl.formatMessage(certStatusMessages.notPassingBody),
buttonVisible: true,
buttonLink: progressLink,
buttonAction: () => {
sendAlertClickTracking('edx.ui.lms.course_outline.certificate_alert_view_grades_button.clicked');
},
};
return alertProps;
};
let alertProps = {};
switch (certStatus) {
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
case CERT_STATUS_TYPE.DOWNLOADABLE:
case CERT_STATUS_TYPE.REQUESTING:
alertProps = renderCertAwardedStatus();
break;
case CERT_STATUS_TYPE.UNVERIFIED:
alertProps = renderNotIDVerifiedStatus();
break;
default:
if (notPassingCourseEnded) {
alertProps = renderNotPassingCourseEnded();
}
break;
}
return (
<AlertWrapper {...alertProps}>
{({
variant,
buttonVisible,
iconClassName,
icon,
header,
body,
buttonAction,
buttonLink,
buttonMessage,
}) => (
<Alert variant={variant}>
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
<div className={buttonVisible ? 'col-lg-8' : 'col-auto'}>
<FontAwesomeIcon icon={icon} className={iconClassName} />
<Alert.Heading>{header}</Alert.Heading>
{body}
</div>
{buttonVisible && (
<div className="flex-grow-0 pt-3 pt-lg-0">
<Button
variant="primary"
href={buttonLink}
onClick={() => {
if (buttonAction) { buttonAction(); }
}}
>
{buttonMessage}
</Button>
</div>
)}
</div>
</Alert>
)}
</AlertWrapper>
);
}
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
certificateAvailableDate: PropTypes.string,
certStatus: PropTypes.string,
courseEndDate: PropTypes.string,
courseId: PropTypes.string,
certURL: PropTypes.string,
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string,
org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool,
tabs: PropTypes.arrayOf(PropTypes.shape({
tab_id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string,
})),
}).isRequired,
};
export default injectIntl(CertificateStatusAlert);

View File

@@ -1,107 +0,0 @@
import React, { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
import { CERT_STATUS_TYPE } from './CertificateStatusAlert';
const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert'));
function verifyCertStatusType(status) {
switch (status) {
case CERT_STATUS_TYPE.DOWNLOADABLE:
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE:
case CERT_STATUS_TYPE.REQUESTING:
case CERT_STATUS_TYPE.UNVERIFIED:
return true;
default:
return false;
}
}
function useCertificateStatusAlert(courseId) {
const VERIFIED_MODES = {
PROFESSIONAL: 'professional',
VERIFIED: 'verified',
NO_ID_PROFESSIONAL_MODE: 'no-id-professional',
CREDIT_MODE: 'credit',
MASTERS: 'masters',
EXECUTIVE_EDUCATION: 'executive-education',
};
const {
isEnrolled,
org,
tabs,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
},
certData,
hasEnded,
userHasPassingGrade,
userTimezone,
enrollmentMode,
} = useModel('outline', courseId);
const {
certStatus,
certWebViewUrl,
certificateAvailableDate,
downloadUrl,
} = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
);
let certURL = '';
if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
} else if (downloadUrl) {
// PDF Certificate
certURL = downloadUrl;
}
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
// Only show if:
// - there is a known cert status that we want provide status on.
// - Or the course has ended and the learner does not have a passing grade.
const isVisible = isEnrolled && hasAlertingCertStatus;
const notPassingCourseEnded = (
isEnrolled
&& isVerifiedEnrollmentMode
&& !hasAlertingCertStatus
&& hasEnded
&& !userHasPassingGrade
);
const payload = {
certificateAvailableDate,
certURL,
certStatus,
courseId,
courseEndDate: endBlock && endBlock.date,
userTimezone,
isWebCert,
org,
notPassingCourseEnded,
tabs,
};
useAlert(isVisible || notPassingCourseEnded, {
code: 'clientCertificateStatusAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return {
clientCertificateStatusAlert: CertificateStatusAlert,
};
}
export default useCertificateStatusAlert;

View File

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

View File

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

View File

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

View File

@@ -6,22 +6,16 @@ import {
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
import { Alert, ALERT_TYPES } from '../../../../generic/user-messages';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
courseId,
} = payload;
const {
start: startDate,
startDate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
@@ -36,7 +30,7 @@ function CourseStartAlert({ payload }) {
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.outline.alert.start.short"
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
@@ -61,7 +55,7 @@ function CourseStartAlert({ payload }) {
}
return (
<Alert variant="info" icon={Info}>
<Alert type={ALERT_TYPES.INFO}>
<strong>
<FormattedMessage
id="learning.outline.alert.end.long"
@@ -93,7 +87,8 @@ function CourseStartAlert({ payload }) {
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
courseId: PropTypes.string,
startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,49 +0,0 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@edx/paragon';
import React from 'react';
import PropTypes from 'prop-types';
function ScheduledContentAlert({ payload }) {
const {
datesTabLink,
} = payload;
return (
<Alert variant="info">
<div className="d-flex flex-column flex-lg-row justify-content-between align-items-center">
<div className="col-lg-7">
<Alert.Heading>
<FormattedMessage
id="learning.outline.alert.scheduled-content.heading"
defaultMessage="More content is coming soon!"
/>
</Alert.Heading>
<FormattedMessage
id="learning.outline.alert.scheduled-content.body"
defaultMessage="This course will have more content released at a future date. Look out for email updates or check back on this course for updates."
/>
</div>
<div className="flex-grow-0 pt-3 pt-lg-0">
{datesTabLink && (
<Button
href={datesTabLink}
>
<FormattedMessage
id="learning.outline.alert.scheduled-content.button"
defaultMessage="View Course Schedule"
/>
</Button>
)}
</div>
</div>
</Alert>
);
}
ScheduledContentAlert.propTypes = {
payload: PropTypes.shape({
datesTabLink: PropTypes.string,
}).isRequired,
};
export default ScheduledContentAlert;

View File

@@ -1,35 +0,0 @@
import React, { useMemo } from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
const ScheduledContentAlert = React.lazy(() => import('./ScheduledCotentAlert'));
const useScheduledContentAlert = (courseId) => {
const {
courseBlocks: {
courses,
},
datesWidget: {
datesTabLink,
},
} = useModel('outline', courseId);
const hasScheduledContent = (
!!courses
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
);
const { isEnrolled } = useModel('courseHomeMeta', courseId);
const payload = {
datesTabLink,
};
useAlert(hasScheduledContent && isEnrolled, {
code: 'ScheduledContentAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
return { ScheduledContentAlert };
};
export default useScheduledContentAlert;

View File

@@ -22,7 +22,7 @@ const messages = defineMessages({
},
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Important dates',
defaultMessage: 'Upcoming Dates',
},
editGoal: {
id: 'learning.outline.editGoal',
@@ -166,7 +166,7 @@ const messages = defineMessages({
},
verifiedProctoringMessage: {
id: 'learning.proctoringPanel.message.verified',
defaultMessage: 'Your onboarding exam has been approved in this course.',
defaultMessage: 'You can now take proctored exams in this course.',
},
rejectedProctoringMessage: {
id: 'learning.proctoringPanel.message.rejected',
@@ -178,7 +178,7 @@ const messages = defineMessages({
},
otherCourseApprovedProctoringMessage: {
id: 'learning.proctoringPanel.message.otherCourseApproved',
defaultMessage: 'Your onboarding exam has been approved in another course.',
defaultMessage: 'You are eligible to take proctored exams in this course.',
},
otherCourseApprovedProctoringDetail: {
id: 'learning.proctoringPanel.detail.otherCourseApproved',
@@ -186,7 +186,7 @@ const messages = defineMessages({
},
expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
defaultMessage: 'Your onboarding profile has been approved in another course, so you are eligible to take proctored exams in this course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
},
proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo',
@@ -216,10 +216,6 @@ const messages = defineMessages({
id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements',
},
proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due',
},
});
export default messages;

View File

@@ -13,13 +13,11 @@ function CourseDates({
/** [MM-P2P] Experiment */
mmp2p,
}) {
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
datesWidget: {
courseDateBlocks,
datesTabLink,
userTimezone,
},
} = useModel('outline', courseId);

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -36,6 +36,16 @@ function CourseTools({ courseId, intl }) {
is_staff: administrator,
tool_name: analyticsId,
});
if (analyticsId === 'edx.tool.verified_upgrade') {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
}
};
const renderIcon = (iconClasses) => {

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import camelCase from 'lodash.camelcase';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
@@ -9,12 +10,10 @@ import messages from '../messages';
import { getProctoringInfoData } from '../../data/api';
function ProctoringInfoPanel({ courseId, username, intl }) {
const [link, setLink] = useState('');
const [onboardingPastDue, setOnboardingPastDue] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
const [status, setStatus] = useState('');
const [readableStatus, setReadableStatus] = useState('');
const [link, setLink] = useState('');
const [releaseDate, setReleaseDate] = useState(null);
const [readableStatus, setReadableStatus] = useState('');
const readableStatuses = {
notStarted: 'notStarted',
@@ -59,9 +58,9 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
function getBorderClass() {
let borderClass = '';
if ([readableStatuses.submitted, readableStatuses.expiringSoon].includes(readableStatus)) {
if (readableStatus === readableStatuses.submitted) {
borderClass = 'proctoring-onboarding-submitted';
} else if ([readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus)) {
} else if (readableStatus === readableStatuses.verified) {
borderClass = 'proctoring-onboarding-success';
}
return borderClass;
@@ -79,10 +78,6 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
.then(
response => {
if (response) {
if (Object.keys(response).length > 0) {
setShowInfoPanel(true);
}
setStatus(response.onboarding_status);
setLink(response.onboarding_link);
const expirationDate = response.expiration_date;
@@ -92,54 +87,14 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
setReadableStatus(getReadableStatusClass(response.onboarding_status));
}
setReleaseDate(new Date(response.onboarding_release_date));
setOnboardingPastDue(response.onboarding_past_due);
}
},
);
}, []);
let onboardingExamButton = null;
if (isNotYetReleased(releaseDate)) {
onboardingExamButton = (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(
messages.proctoringOnboardingButtonNotOpen,
{
releaseDate: intl.formatDate(releaseDate, {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
)}
</Button>
);
} else if (onboardingPastDue) {
onboardingExamButton = (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(messages.proctoringOnboardingButtonPastDue)}
</Button>
);
} else if (!isNotYetReleased(releaseDate)) {
if (readableStatus === readableStatuses.otherCourseApproved) {
onboardingExamButton = (
<Button variant="primary" block href={link}>
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
</Button>
);
} else if (readableStatus !== readableStatuses.otherCourseApproved) {
onboardingExamButton = (
<Button variant="primary" block href={link}>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</Button>
);
}
}
return (
<>
{ showInfoPanel && (
{ link && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.proctoringInfoPanel)}</h2>
<div>
@@ -160,17 +115,50 @@ function ProctoringInfoPanel({ courseId, username, intl }) {
<>
<p>
{isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfo)
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfo)}
</>
)}
{!isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
<>
{intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)}
</>
)}
</p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</>
)}
{isNotYetSubmitted(status) && (
onboardingExamButton
<>
{!isNotYetReleased(releaseDate) && (
<Button variant="primary" block href={`${getConfig().LMS_BASE_URL}${link}`}>
{readableStatus === readableStatuses.otherCourseApproved && (
<>
{intl.formatMessage(messages.proctoringOnboardingPracticeButton)}
</>
)}
{readableStatus !== readableStatuses.otherCourseApproved && (
<>
{intl.formatMessage(messages.proctoringOnboardingButton)}
</>
)}
</Button>
)}
{isNotYetReleased(releaseDate) && (
<Button variant="secondary" block disabled aria-disabled="true">
{intl.formatMessage(
messages.proctoringOnboardingButtonNotOpen,
{
releaseDate: intl.formatDate(releaseDate, {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
)}
</Button>
)}
</>
)}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
{intl.formatMessage(messages.proctoringReviewRequirementsButton)}

View File

@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { UpgradeButton } from '../../../generic/upgrade-button';
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
function UpgradeCard({ courseId, intl, onLearnMore }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
offer,
verifiedMode,
} = useModel('outline', courseId);
if (!verifiedMode) {
return null;
}
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const promotionEventProperties = {
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
...eventProperties,
};
useEffect(() => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
sendTrackEvent('Promotion Viewed', promotionEventProperties);
}, []);
const logClick = () => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
...eventProperties,
location: 'sidebar-message',
});
sendTrackEvent('Promotion Clicked', promotionEventProperties);
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: 'green_upgrade',
linkName: 'course_home_green',
linkType: 'button',
pageName: 'course_home',
});
};
return (
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
<div className="row w-100 m-0">
<div className="col-6 col-md-12 col-lg-3 col-xl-4 p-0 text-md-center text-lg-left">
<img
alt={intl.formatMessage(messages.certAlt)}
className="w-100"
src={VerifiedCert}
style={{ maxWidth: '10rem' }}
/>
</div>
<div className="col-6 col-md-12 col-lg-9 col-xl-8 p-0 pl-lg-2 text-center mt-md-2 mt-lg-0">
<div className="row w-100 m-0 justify-content-center">
<UpgradeButton
offer={offer}
onClick={logClick}
verifiedMode={verifiedMode}
/>
{onLearnMore && (
<div className="col-12">
<Button
variant="link"
size="sm"
className="pb-0"
onClick={onLearnMore}
aria-labelledby="outline-sidebar-upgrade-header"
>
{intl.formatMessage(messages.learnMore)}
</Button>
</div>
)}
</div>
</div>
</div>
</section>
);
}
UpgradeCard.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLearnMore: PropTypes.func,
};
UpgradeCard.defaultProps = {
onLearnMore: null,
};
export default injectIntl(UpgradeCard);

View File

@@ -0,0 +1,4 @@
.outline-sidebar-upgrade-card {
border: 1px solid $dark-500;
border-top: 5px solid $dark-500;
}

View File

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

View File

@@ -12,23 +12,16 @@ import messages from './messages';
function ProgressHeader({ intl }) {
const {
courseId,
targetUserId,
} = useSelector(state => state.courseHome);
const { administrator, userId } = getAuthenticatedUser();
const { administrator } = getAuthenticatedUser();
const { studioUrl, username } = useModel('progress', courseId);
const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId);
const pageTitle = viewingOtherStudentsProgressPage
? intl.formatMessage(messages.progressHeaderForTargetUser, { username })
: intl.formatMessage(messages.progressHeader);
const { studioUrl } = useModel('progress', courseId);
return (
<>
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
<h1>{pageTitle}</h1>
<div className="row w-100 m-0 mb-4 justify-content-between">
<h1>{intl.formatMessage(messages.progressHeader)}</h1>
{administrator && studioUrl && (
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
{intl.formatMessage(messages.studioLink)}

View File

@@ -1,6 +1,4 @@
import React from 'react';
import { layoutGenerator } from 'react-break';
import { useSelector } from 'react-redux';
import CertificateStatus from './certificate-status/CertificateStatus';
import CourseCompletion from './course-completion/CourseCompletion';
@@ -10,48 +8,24 @@ import GradeSummary from './grades/grade-summary/GradeSummary';
import ProgressHeader from './ProgressHeader';
import RelatedLinks from './related-links/RelatedLinks';
import { useModel } from '../../generic/model-store';
function ProgressTab() {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const layout = layoutGenerator({
mobile: 0,
desktop: 992,
});
const OnMobile = layout.is('mobile');
const OnDesktop = layout.isAtLeast('desktop');
return (
<>
<ProgressHeader />
<div className="row w-100 m-0">
{/* Main body */}
<div className="col-12 col-md-8 p-0">
<div className="col-12 col-lg-8 p-0">
<CourseCompletion />
<OnMobile>
<CertificateStatus />
</OnMobile>
<CourseGrade />
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
<div className="my-4 p-4 rounded shadow-sm">
<GradeSummary />
<DetailedGrades />
</div>
</div>
{/* Side panel */}
<div className="col-12 col-md-4 p-0 px-md-4">
<OnDesktop>
<CertificateStatus />
</OnDesktop>
<div className="col-12 col-lg-4 p-0 px-lg-4">
<CertificateStatus />
<RelatedLinks />
</div>
</div>

View File

@@ -1,18 +1,16 @@
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import {
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, act,
initializeMockApp, logUnhandledRequests, render, screen, act,
} from '../../setupTest';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -21,20 +19,14 @@ describe('Progress Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
const defaultTabData = Factory.build('progressTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
function setTabData(attributes, options) {
const progressTabData = Factory.build('progressTabData', attributes, options);
axiosMock.onGet(progressUrl).reply(200, progressTabData);
@@ -51,444 +43,10 @@ describe('Progress Tab', () => {
// Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
logUnhandledRequests(axiosMock);
});
describe('Related links', () => {
beforeEach(() => {
sendTrackEvent.mockClear();
});
it('sends event on click of dates tab link', async () => {
await fetchAndRender();
const datesTabLink = screen.getByRole('link', { name: 'Dates' });
fireEvent.click(datesTabLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.related_links.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
link_clicked: 'dates',
});
});
it('sends event on click of outline tab link', async () => {
await fetchAndRender();
const outlineTabLink = screen.getAllByRole('link', { name: 'Course Outline' });
fireEvent.click(outlineTabLink[1]); // outlineTabLink[0] corresponds to the link in the DetailedGrades component
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.related_links.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
link_clicked: 'course_outline',
});
});
});
describe('Course Grade', () => {
it('renders Course Grade', async () => {
await fetchAndRender();
expect(screen.getByText('Grades')).toBeInTheDocument();
expect(screen.getByText('This represents your weighted grade against the grade needed to pass this course.')).toBeInTheDocument();
});
it('renders correct copy in CourseGradeFooter for non-passing', async () => {
setTabData({
course_grade: {
is_passing: false,
letter_grade: null,
percent: 0.5,
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 0.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
});
it('renders correct copy in CourseGradeFooter for passing with pass/fail grade range', async () => {
await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
expect(screen.getByText('Youre currently passing this course')).toBeInTheDocument();
});
it('renders correct copy and tooltip in CourseGradeFooter for non-passing with letter grade range', async () => {
setTabData({
course_grade: {
is_passing: false,
letter_grade: null,
percent: 0,
},
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
A: 0.9,
B: 0.8,
},
},
});
await fetchAndRender();
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
});
it('renders correct copy and tooltip in CourseGradeFooter for passing with letter grade range', async () => {
setTabData({
course_grade: {
is_passing: true,
letter_grade: 'B',
percent: 0.8,
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
A: 0.9,
B: 0.8,
},
},
});
await fetchAndRender();
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
expect(await screen.findByText('Youre currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
});
it('renders tooltip in CourseGradeFooter for grade range', async () => {
setTabData({
course_grade: {
percent: 0,
is_passing: false,
},
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
A: 0.9,
B: 0.8,
},
},
});
await fetchAndRender();
const tooltip = await screen.getByRole('button', { name: 'Grade range tooltip' });
fireEvent.click(tooltip);
expect(screen.getByText('Grade ranges for this course:'));
expect(screen.getByText('A: 90%-100%'));
expect(screen.getByText('B: 80%-90%'));
expect(screen.getByText('F: <80%'));
});
it('renders locked feature preview (CourseGradeHeader) with upgrade button when user has locked content', async () => {
setTabData({
completion_summary: {
complete_count: 1,
incomplete_count: 1,
locked_count: 1,
},
verified_mode: {
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade',
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
expect(screen.getAllByRole('link', 'Unlock now')).toHaveLength(3);
});
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
sendTrackEvent.mockClear();
setTabData({
completion_summary: {
complete_count: 1,
incomplete_count: 1,
locked_count: 1,
},
verified_mode: {
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade',
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to view grades and work towards a certificate.')).toBeInTheDocument();
const upgradeButton = screen.getAllByRole('link', 'Unlock now')[0];
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.grades_upgrade.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
});
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'progress_locked',
linkType: 'button',
pageName: 'progress',
});
});
it('renders locked feature preview with no upgrade button when user has locked content but cannot upgrade', async () => {
setTabData({
completion_summary: {
complete_count: 1,
incomplete_count: 1,
locked_count: 1,
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument();
expect(screen.getByText('The deadline to upgrade in this course has passed.')).toBeInTheDocument();
});
it('does not render locked feature preview when user does not have locked content', async () => {
await fetchAndRender();
expect(screen.queryByText('locked feature')).not.toBeInTheDocument();
});
it('renders limited feature preview with upgrade button when user has access to some content that would typically be locked', async () => {
setTabData({
completion_summary: {
complete_count: 1,
incomplete_count: 1,
locked_count: 1,
},
verified_mode: {
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade',
},
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@123456',
display_name: 'First subsection',
learner_has_access: false,
has_graded_assignment: true,
num_points_earned: 8,
num_points_possible: 10,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
{
assignment_type: 'Exam',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByText('limited feature')).toBeInTheDocument();
expect(screen.getByText('Unlock to work towards a certificate.')).toBeInTheDocument();
expect(screen.queryAllByText('You have limited access to graded assignments as part of the audit track in this course.')).toHaveLength(2);
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
});
it('renders correct current grade tooltip when showGrades is false', async () => {
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
setTabData({
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: false,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
// Although the learner's true grade is passing, we should expect this to reflect the grade that's
// visible to them, which is non-passing
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
});
});
describe('Grade Summary', () => {
it('renders Grade Summary table when assignment policies are populated', async () => {
await fetchAndRender();
@@ -499,186 +57,11 @@ describe('Progress Tab', () => {
setTabData({
grading_policy: {
assignment_policies: [],
grade_range: {
pass: 0.75,
},
},
section_scores: [],
});
await fetchAndRender();
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 2,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of droppable assignments is zero', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
});
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
});
it('calculates weighted grades correctly', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 0.5,
},
{
num_droppable: 0,
num_total: 1,
short_label: 'Ex',
type: 'Exam',
weight: 0.5,
},
],
grade_range: {
pass: 0.75,
},
},
});
await fetchAndRender();
expect(screen.getByText('Grade summary')).toBeInTheDocument();
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
});
it('renders correct total weighted grade when showGrades is false', async () => {
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
setTabData({
section_scores: [
{
display_name: 'First section',
subsections: [
{
assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection',
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 2,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
},
],
},
{
display_name: 'Second section',
subsections: [
{
assignment_type: 'Homework',
display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true,
num_points_earned: 1,
num_points_possible: 1,
percent_graded: 1.0,
show_correctness: 'always',
show_grades: false,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
},
],
},
],
});
await fetchAndRender();
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
});
});
describe('Detailed Grades', () => {
@@ -686,55 +69,8 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('First subsection'));
expect(screen.getByText('Second subsection'));
});
it('sends event on click of subsection link', async () => {
sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const subsectionLink = screen.getByRole('link', { name: 'First subsection' });
fireEvent.click(subsectionLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.detailed_grades_assignment.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
assignment_block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
});
});
it('sends event on click of course outline link', async () => {
sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const outlineLink = screen.getAllByRole('link', { name: 'Course Outline' })[0];
fireEvent.click(outlineLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.detailed_grades.course_outline_link.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
});
});
it('renders individual problem score drawer', async () => {
sendTrackEvent.mockClear();
await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument();
const problemScoreDrawerToggle = screen.getByRole('button', { name: 'Toggle individual problem scores for First subsection' });
expect(problemScoreDrawerToggle).toBeInTheDocument();
// Open the problem score drawer
fireEvent.click(problemScoreDrawerToggle);
expect(screen.getByText('Problem Scores:')).toBeInTheDocument();
expect(screen.getAllByText('0/1')).toHaveLength(3);
expect(screen.getByRole('link', { name: 'First subsection' }));
expect(screen.getByRole('link', { name: 'Second subsection' }));
});
it('render message when section scores are not populated', async () => {
@@ -746,516 +82,4 @@ describe('Progress Tab', () => {
expect(screen.getByText('You currently have no graded problem scores.')).toBeInTheDocument();
});
});
describe('Certificate Status', () => {
beforeAll(() => {
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', () => {
beforeEach(async () => {
setMetadata({ is_enrolled: true });
sendTrackEvent.mockClear();
});
it('Displays text for nonPassing case when learner does not have a passing grade', async () => {
await fetchAndRender();
expect(screen.getByText('In order to qualify for a certificate, you must have a passing grade.')).toBeInTheDocument();
});
it('sends event when visiting progress tab when learner is not passing', async () => {
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: 'not_passing',
certificate_status_variant: 'not_passing',
});
});
it('Displays text for inProgress case when more content is scheduled and the learner does not have a passing grade', async () => {
setTabData({
has_scheduled_content: true,
});
await fetchAndRender();
expect(screen.getByText('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.')).toBeInTheDocument();
});
it('sends event when visiting progress tab when user has scheduled content', async () => {
setTabData({
has_scheduled_content: 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: 'not_passing',
certificate_status_variant: 'has_scheduled_content',
});
});
it('Displays request certificate link', async () => {
setTabData({
certificate_data: { cert_status: 'requesting' },
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByRole('button', { name: 'Request certificate' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of request certificate link', async () => {
setTabData({
certificate_data: { cert_status: 'requesting' },
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: 'requesting',
});
const requestCertificateLink = screen.getByRole('button', { name: 'Request certificate' });
fireEvent.click(requestCertificateLink);
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: 'requesting',
});
});
it('Displays verify identity link', async () => {
setTabData({
certificate_data: { cert_status: 'unverified' },
user_has_passing_grade: true,
verification_data: { link: 'test' },
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Verify ID' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of ID verification link', async () => {
setTabData({
certificate_data: { cert_status: 'unverified' },
user_has_passing_grade: true,
verification_data: { link: 'test' },
});
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: 'unverified',
});
const idVerificationLink = screen.getByRole('link', { name: 'Verify ID' });
fireEvent.click(idVerificationLink);
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: 'unverified',
});
});
it('Displays verification pending message', async () => {
setTabData({
certificate_data: { cert_status: 'unverified' },
verification_data: { status: 'pending' },
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Verify ID' })).not.toBeInTheDocument();
});
it('sends event when visiting progress tab with ID verification pending message', async () => {
setTabData({
certificate_data: { cert_status: 'unverified' },
verification_data: { status: 'pending' },
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: 'unverified',
});
});
it('Displays download link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
track_variant: 'audit',
grade_variant: 'passing',
certificate_status_variant: 'earned_downloadable',
});
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
fireEvent.click(downloadCertificateLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
certificate_status_variant: 'earned_downloadable',
});
});
it('Displays webview link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
cert_web_view_url: '/certificates/cooluuidgoeshere',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'View my certificate' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of view certificate link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
cert_web_view_url: '/certificates/cooluuidgoeshere',
},
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_viewable',
});
const viewCertificateLink = screen.getByRole('link', { name: 'View my certificate' });
fireEvent.click(viewCertificateLink);
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_viewable',
});
});
it('Displays certificate is earned but unavailable message', async () => {
setTabData({
certificate_data: { cert_status: 'earned_but_not_available' },
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.queryByText('Certificate status')).toBeInTheDocument();
});
it('sends event when visiting the progress tab when cert is earned but unavailable', async () => {
setTabData({
certificate_data: { cert_status: 'earned_but_not_available' },
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_but_not_available',
});
});
it('sends event with correct grade variant for passing with letter grades', async () => {
setTabData({
certificate_data: { cert_status: 'earned_but_not_available' },
grading_policy: {
assignment_policies: [
{
num_droppable: 1,
num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
},
],
grade_range: {
A: 0.9,
B: 0.8,
},
},
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_grades',
certificate_status_variant: 'earned_but_not_available',
});
});
it('Displays upgrade link when available', async () => {
setTabData({
certificate_data: { cert_status: 'audit_passing' },
verified_mode: {
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
},
});
await fetchAndRender();
// Keep these text checks in sync with "audit only" test below, so it doesn't end up checking for text that is
// never actually there, when/if the text changes.
expect(screen.getByText('You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade now' })).toBeInTheDocument();
});
it('sends events on view of progress tab and when audit learner clicks upgrade link', async () => {
setTabData({
certificate_data: { cert_status: 'audit_passing' },
verified_mode: {
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
},
});
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: 'not_passing',
certificate_status_variant: 'audit_passing',
});
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
certificate_status_variant: 'audit_passing',
});
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'progress_certificate',
linkType: 'button',
pageName: 'progress',
});
});
it('Displays nothing if audit only', async () => {
setTabData({
certificate_data: { cert_status: 'audit_passing' },
});
await fetchAndRender();
// Keep these queries in sync with "upgrade link" test above, so we don't end up checking for text that is
// never actually there, when/if the text changes.
expect(screen.queryByText('You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Upgrade now' })).not.toBeInTheDocument();
});
it('sends event when visiting the progress tab even when audit user cannot upgrade (i.e. certificate component does not render)', async () => {
setTabData({
certificate_data: { cert_status: 'audit_passing' },
});
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: 'not_passing',
certificate_status_variant: 'audit_passing_missed_upgrade_deadline',
});
});
it('Does not display the certificate component if it does not match any statuses', async () => {
setTabData({
certificate_data: {
cert_status: 'bogus_status',
},
user_has_passing_grade: true,
});
setMetadata({ is_enrolled: true });
await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
});
it('sends event when visiting progress tab, although no certificate statuses match', async () => {
setTabData({
certificate_data: {
cert_status: 'bogus_status',
},
user_has_passing_grade: true,
});
setMetadata({ is_enrolled: 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: 'certificate_status_disabled',
});
});
});
it('Does not display the certificate component if the user is not enrolled', async () => {
await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
});
});
describe('Access expiration masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
},
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: false,
},
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
});
});
describe('Course start masquerade banner', () => {
it('renders banner when masquerading as a user', async () => {
setMetadata({
is_enrolled: true,
original_user_is_staff: true,
is_staff: false,
start: '2999-01-01T00:00:00Z',
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({
is_enrolled: true,
original_user_is_staff: true,
is_staff: true,
start: '2999-01-01T00:00:00Z',
});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
});
});
describe('Viewing progress page of other students by changing url', () => {
it('Changing the url changes the header', async () => {
setMetadata({ is_enrolled: true });
setTabData({ username: 'otherstudent' });
await executeThunk(thunks.fetchProgressTab(courseId, 10), store.dispatch);
await act(async () => render(<ProgressTab />, { store }));
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
});
});
});

View File

@@ -1,255 +1,12 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Button, Card } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../generic/model-store';
import { COURSE_EXIT_MODES, getCourseExitMode } from '../../../courseware/course/course-exit/utils';
import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../shared/links';
import { requestCert } from '../../data/thunks';
import messages from './messages';
function CertificateStatus({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
isEnrolled,
org,
} = useModel('courseHomeMeta', courseId);
const {
certificateData,
end,
enrollmentMode,
gradingPolicy: {
gradeRange,
},
hasScheduledContent,
userHasPassingGrade,
verificationData,
verifiedMode,
} = useModel('progress', courseId);
const {
certificateAvailableDate,
} = certificateData || {};
const mode = getCourseExitMode(
certificateData,
hasScheduledContent,
isEnrolled,
userHasPassingGrade,
);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const dispatch = useDispatch();
const { administrator } = getAuthenticatedUser();
let certStatus;
let certWebViewUrl;
let downloadUrl;
if (certificateData) {
certStatus = certificateData.certStatus;
certWebViewUrl = certificateData.certWebViewUrl;
downloadUrl = certificateData.downloadUrl;
}
let certCase;
let certEventName = certStatus;
let body;
let buttonAction;
let buttonLocation;
let buttonText;
let endDate;
let certAvailabilityDate;
let gradeEventName = 'not_passing';
if (userHasPassingGrade) {
gradeEventName = Object.entries(gradeRange).length > 1 ? 'passing_grades' : 'passing';
}
const dashboardLink = <DashboardLink />;
const idVerificationSupportLink = <IdVerificationSupportLink />;
const profileLink = <ProfileLink />;
// Some learners have a valid ("downloadable") certificate without being in a passing
// state (e.g. learners who have been added to a course's allowlist), so we need to
// skip grade validation for these learners
const certIsDownloadable = certStatus === 'downloadable';
if (mode === COURSE_EXIT_MODES.disabled) {
certEventName = 'certificate_status_disabled';
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) {
certCase = 'notPassing';
certEventName = 'not_passing';
body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) {
certCase = 'inProgress';
certEventName = 'has_scheduled_content';
body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.celebration || certIsDownloadable) {
switch (certStatus) {
case 'requesting':
certCase = 'requestable';
buttonAction = () => { dispatch(requestCert(courseId)); };
body = intl.formatMessage(messages[`${certCase}Body`]);
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
break;
case 'unverified':
certCase = 'unverified';
if (verificationData.status === 'pending') {
body = (<p>{intl.formatMessage(messages.unverifiedPendingBody)}</p>);
} else {
body = (
<FormattedMessage
id="progress.certificateStatus.unverifiedBody"
defaultMessage="In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}."
values={{ idVerificationSupportLink }}
/>
);
buttonLocation = verificationData.link;
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
}
break;
case 'downloadable':
// Certificate available, download/viewable
certCase = 'downloadable';
body = (
<FormattedMessage
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
{dashboardLink} and {profileLink}."
values={{ dashboardLink, profileLink }}
/>
);
if (certWebViewUrl) {
certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton);
} else if (downloadUrl) {
certEventName = 'earned_downloadable';
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadableButton);
}
break;
case 'earned_but_not_available':
certCase = 'notAvailable';
endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
certAvailabilityDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
body = (
<FormattedMessage
id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="This course ends on {endDate}. Final grades and certificates are
scheduled to be available after {certAvailabilityDate}."
values={{ endDate, certAvailabilityDate }}
/>
);
break;
case 'audit_passing':
case 'honor_passing':
if (verifiedMode) {
certCase = 'upgrade';
body = intl.formatMessage(messages[`${certCase}Body`]);
buttonLocation = verifiedMode.upgradeUrl;
buttonText = intl.formatMessage(messages[`${certCase}Button`]);
} else {
certCase = null; // Do not render the certificate component if the upgrade deadline has passed
certEventName = 'audit_passing_missed_upgrade_deadline';
}
break;
// This code shouldn't be hit but coding defensively since switch expects a default statement
default:
certCase = null;
certEventName = 'no_certificate_status';
break;
}
}
// Log visit to progress tab
useEffect(() => {
sendTrackEvent('edx.ui.lms.course_progress.visited', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
track_variant: enrollmentMode,
grade_variant: gradeEventName,
certificate_status_variant: certEventName,
});
}, []);
if (!certCase) {
return null;
}
const header = intl.formatMessage(messages[`${certCase}Header`]);
const logCertificateStatusButtonClicked = () => {
sendTrackEvent('edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: org,
courserun_key: courseId,
is_staff: administrator,
certificate_status_variant: certEventName,
});
if (certCase === 'upgrade') {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: '(none)',
linkName: 'progress_certificate',
linkType: 'button',
pageName: 'progress',
});
}
};
import React from 'react';
function CertificateStatus() {
return (
<section data-testid="certificate-status-component" className="text-dark-700 mb-4">
<Card className="bg-light-200 shadow-sm border-0">
<Card.Body>
<Card.Title>
<h3>{header}</h3>
</Card.Title>
<Card.Text className="small text-gray-700">
{body}
</Card.Text>
{buttonText && (buttonLocation || buttonAction) && (
<Button
variant="outline-brand"
onClick={() => {
logCertificateStatusButtonClicked(certStatus);
if (buttonAction) { buttonAction(); }
}}
href={buttonLocation}
block
>
{buttonText}
</Button>
)}
</Card.Body>
</Card>
<section className="text-dark-700 rounded shadow-sm mb-4 p-4">
{/* TODO: AA-719 */}
<h3 className="h4">Certificate status</h3>
</section>
);
}
CertificateStatus.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CertificateStatus);
export default CertificateStatus;

View File

@@ -1,90 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
notPassingHeader: {
id: 'progress.certificateStatus.notPassingHeader',
defaultMessage: 'Certificate status',
},
notPassingBody: {
id: 'progress.certificateStatus.notPassingBody',
defaultMessage: 'In order to qualify for a certificate, you must have a passing grade.',
},
inProgressHeader: {
id: 'progress.certificateStatus.inProgressHeader',
defaultMessage: 'More content is coming soon!',
},
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.',
},
requestableHeader: {
id: 'progress.certificateStatus.requestableHeader',
defaultMessage: 'Certificate status',
},
requestableBody: {
id: 'progress.certificateStatus.requestableBody',
defaultMessage: 'Congratulations, you qualified for a certificate! In order to access your certificate, request it below.',
},
requestableButton: {
id: 'progress.certificateStatus.requestableButton',
defaultMessage: 'Request certificate',
},
unverifiedHeader: {
id: 'progress.certificateStatus.unverifiedHeader',
defaultMessage: 'Certificate status',
},
unverifiedButton: {
id: 'progress.certificateStatus.unverifiedButton',
defaultMessage: 'Verify ID',
},
unverifiedPendingBody: {
id: 'progress.certificateStatus.courseCelebration.verificationPending',
defaultMessage: 'Your ID verification is pending and your certificate will be available once approved.',
},
downloadableHeader: {
id: 'progress.certificateStatus.downloadableHeader',
defaultMessage: 'Your certificate is available!',
},
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.',
},
downloadableButton: {
id: 'progress.certificateStatus.downloadableButton',
defaultMessage: 'Download my certificate',
},
viewableButton: {
id: 'progress.certificateStatus.viewableButton',
defaultMessage: 'View my certificate',
},
notAvailableHeader: {
id: 'progress.certificateStatus.notAvailableHeader',
defaultMessage: 'Certificate status',
},
upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate',
},
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.',
},
upgradeButton: {
id: 'progress.certificateStatus.upgradeButton',
defaultMessage: 'Upgrade now',
},
unverifiedHomeHeader: {
id: 'progress.certificateStatus.unverifiedHomeHeader',
defaultMessage: 'Verify your identity to earn a certificate!',
},
unverifiedHomeButton: {
id: 'progress.certificateStatus.unverifiedHomeButton',
defaultMessage: 'Verify my ID',
},
unverifiedHomeBody: {
id: 'progress.certificateStatus.unverifiedHomeBody',
defaultMessage: 'In order to generate a certificate for this course, you must complete the ID verification process.',
},
});
export default messages;

View File

@@ -7,10 +7,6 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from './messages';
function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
if (!completePercentage) {
return null;
}
const [showCompletePopover, setShowCompletePopover] = useState(false);
const completeSegmentOffset = (3.6 * completePercentage) / 8;
@@ -28,6 +24,15 @@ function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
onFocus={() => setShowCompletePopover(true)}
tabIndex="-1"
>
<circle
className="donut-segment complete-stroke"
cx="21"
cy="21"
r="15.91549430918954"
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
strokeDashoffset={lockedSegmentOffset + completePercentage}
/>
{/* Tooltip */}
<OverlayTrigger
show={showCompletePopover}
@@ -44,33 +49,16 @@ function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
<rect x="19" y="3" style={{ transform: `rotate(${completeTooltipDegree}deg)` }} />
</OverlayTrigger>
{/* Complete segment */}
<circle
className="donut-segment complete-stroke"
cx="21"
cy="21"
r="15.91549430918954"
strokeDasharray={`${completePercentage} ${100 - completePercentage}`}
strokeDashoffset={lockedSegmentOffset + completePercentage}
/>
{/* Segment dividers */}
{lockedPercentage > 0 && lockedPercentage < 100 && (
<circle
cx="21"
cy="21"
r="15.91549430918954"
className="donut-segment divider-stroke"
strokeDasharray="0.3 99.7"
strokeDashoffset={0.15 + lockedSegmentOffset}
/>
)}
{completePercentage < 100 && lockedPercentage > 0 && lockedPercentage < 100
&& lockedPercentage + completePercentage === 100 && (
{completePercentage < 100 && lockedPercentage < 100 && lockedPercentage + completePercentage === 100 && (
<circle
cx="21"
cy="21"
r="15.91549430918954"
className="donut-segment divider-stroke"
strokeDasharray="0.3 99.7"
strokeDashoffset="25.15"

View File

@@ -22,8 +22,8 @@ function CompletionDonutChart({ intl }) {
} = useModel('progress', courseId);
const numTotalUnits = completeCount + incompleteCount + lockedCount;
const completePercentage = completeCount ? Number(((completeCount / numTotalUnits) * 100).toFixed(0)) : 0;
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
const completePercentage = Number(((completeCount / numTotalUnits) * 100).toFixed(0));
const lockedPercentage = Number(((lockedCount / numTotalUnits) * 100).toFixed(0));
const incompletePercentage = 100 - completePercentage - lockedPercentage;
return (

View File

@@ -7,10 +7,6 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from './messages';
function IncompleteDonutSegment({ incompletePercentage, intl }) {
if (!incompletePercentage) {
return null;
}
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;

View File

@@ -9,7 +9,7 @@ import messages from './messages';
function LockedDonutSegment({ intl, lockedPercentage }) {
const [showLockedPopover, setShowLockedPopover] = useState(false);
if (!lockedPercentage) {
if (!lockedPercentage > 0) {
return null;
}

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