Compare commits

..

2 Commits

Author SHA1 Message Date
Matthew Piatetsky
ffe10e56f5 feat: add user id parameter to progress page 2021-06-23 17:19:46 -04:00
Matthew Piatetsky
15027ab69e feat: change color of start/resume course button 2021-06-21 10:50:25 -04:00
228 changed files with 12409 additions and 17348 deletions

9
.env
View File

@@ -1,10 +1,6 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='production' NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME='' ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL='' BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL='' CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH='' CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL='' DISCOVERY_API_BASE_URL=''
@@ -20,7 +16,6 @@ LOGO_URL=''
LOGO_TRADEMARK_URL='' LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL='' LOGO_WHITE_URL=''
FAVICON_URL='' FAVICON_URL=''
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='' MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL='' ORDER_HISTORY_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='' REFRESH_ACCESS_TOKEN_ENDPOINT=''
@@ -37,6 +32,4 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG='' TWITTER_HASHTAG=''
TWITTER_URL='' TWITTER_URL=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
SESSION_COOKIE_DOMAIN='' SESSION_COOKIE_DOMAIN=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -1,10 +1,6 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='development' NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000' BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150' CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -19,7 +15,6 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
@@ -37,6 +32,4 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost' SESSION_COOKIE_DOMAIN='localhost'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -1,10 +1,6 @@
# See README.rst for explanations of these.
# If you add a new learning MFE-specific variable, please note it there!
NODE_ENV='test' NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2000' BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150' CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token' CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381' DISCOVERY_API_BASE_URL='http://localhost:18381'
@@ -19,7 +15,6 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000' MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders' ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000 PORT=2000
@@ -37,5 +32,3 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

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 - 12
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v1
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- run: make validate.ci - run: make validate.ci
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v2 uses: codecov/codecov-action@v1
with: with:
fail_ci_if_error: true fail_ci_if_error: true

4
.gitignore vendored
View File

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

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 frontend-app-learning
========================= =========================
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
Introduction Introduction
------------ ------------
This is the Learning MFE (micro-frontend application), which renders all React app for edX learning.
learner-facing course pages (like the course outline, the progress page,
actual course content, etc).
Please tag **@edx/engage-squad** on any PRs or issues. Thanks. .. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-learning
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3 .. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
:target: https://codecov.io/gh/edx/frontend-app-learning :target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational .. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE :target: @edx/frontend-app-learning
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
:target: @edx/frontend-app-learning
Development Development
----------- -----------
@@ -23,10 +25,22 @@ Development
Start Devstack Start Devstack
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
To use this application, `devstack <https://github.com/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`` - Start devstack
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key. - Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In this project, install requirements and start the development server by running:
.. code:: bash
npm install
npm start # The server will run on port 1995
Once the dev server is up, visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Local module development Local module development
^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
@@ -53,65 +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. 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
ENABLE_JUMPNAV
Enables the new Jump Navigation feature in the course breadcrumbs, defaulted to the string 'true'.
Disable to have simple hyperlinks for breadcrumbs. Setting it to any other value but 'true' ('false','I love flags', 'etc' would disable the Jumpnav).
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
https://openedx.atlassian.net/browse/TNL-8678

View File

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

View File

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

18156
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", "is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .", "lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .", "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"prepare": "husky install",
"snapshot": "fedx-scripts jest --updateSnapshot", "snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress", "start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests" "test": "fedx-scripts jest --coverage --passWithNoTests"
}, },
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme", "homepage": "https://github.com/edx/frontend-app-learning#readme",
@@ -32,52 +36,49 @@
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6", "@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-enterprise-utils": "1.1.1", "@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-lib-special-exams": "1.14.1", "@edx/frontend-lib-special-exams": "1.0.0",
"@edx/frontend-platform": "1.14.3", "@edx/frontend-platform": "1.11.0",
"@edx/paragon": "16.19.0", "@edx/paragon": "14.8.0",
"@edx/frontend-component-header": "^2.4.3", "@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/react-fontawesome": "0.1.14",
"@fortawesome/react-fontawesome": "0.1.15", "@reduxjs/toolkit": "1.3.6",
"@pact-foundation/pact": "9.16.4", "classnames": "2.2.6",
"@reduxjs/toolkit": "1.6.2", "core-js": "3.6.5",
"classnames": "2.3.1", "js-cookie": "2.2.1",
"core-js": "3.18.3", "lodash.camelcase": "^4.3.0",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.7.2", "prop-types": "15.7.2",
"react": "17.0.2", "react": "16.13.1",
"react-break": "1.3.2", "react-break": "1.3.2",
"react-dom": "17.0.2", "react-dom": "16.13.1",
"react-helmet": "6.1.0", "react-helmet": "6.0.0",
"react-redux": "7.2.5", "react-redux": "7.2.4",
"react-router": "5.2.1", "react-router": "5.2.0",
"react-router-dom": "5.3.0", "react-router-dom": "5.2.0",
"react-share": "4.4.0", "react-share": "4.2.1",
"redux": "4.1.1", "redux": "4.0.5",
"regenerator-runtime": "0.13.9", "regenerator-runtime": "0.13.7",
"reselect": "4.0.0", "reselect": "4.0.0",
"truncate-html": "1.0.4", "truncate-html": "1.0.3"
"util": "0.12.4"
}, },
"devDependencies": { "devDependencies": {
"@edx/frontend-build": "9.0.5", "@edx/frontend-build": "5.5.5",
"@testing-library/dom": "7.16.3", "@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/react": "10.3.0",
"@testing-library/user-event": "13.4.1", "@testing-library/user-event": "12.0.17",
"axios-mock-adapter": "1.20.0", "axios-mock-adapter": "1.18.2",
"codecov": "3.8.3", "codecov": "3.8.2",
"es-check": "6.0.0", "es-check": "5.1.4",
"glob": "7.2.0", "glob": "7.1.7",
"husky": "7.0.2", "husky": "3.1.0",
"jest": "27.2.5", "jest": "24.9.0",
"jest-chain": "1.1.5", "jest-chain": "1.1.5",
"reactifex": "1.1.1", "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 { import {
FormattedMessage, FormattedDate, injectIntl, intlShape, FormattedMessage, FormattedDate, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon'; import { Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages'; import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P'; import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
@@ -35,10 +35,34 @@ function AccessExpirationAlert({ intl, payload }) {
const { const {
expirationDate, expirationDate,
masqueradingExpiredCourse,
upgradeDeadline, upgradeDeadline,
upgradeUrl, upgradeUrl,
} = accessExpiration; } = accessExpiration;
if (masqueradingExpiredCourse) {
return (
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.accessExpiration.expired"
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
values={{
date: (
<FormattedDate
key="accessExpirationExpiredDate"
day="numeric"
month="short"
year="numeric"
value={expirationDate}
{...timezoneFormatArgs}
/>
),
}}
/>
</Alert>
);
}
/** [MM-P2P] Experiment */ /** [MM-P2P] Experiment */
if (showMMP2P) { if (showMMP2P) {
return ( return (
@@ -92,7 +116,7 @@ function AccessExpirationAlert({ intl, payload }) {
} }
return ( return (
<Alert variant="info" icon={Info}> <Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold"> <span className="font-weight-bold">
<FormattedMessage <FormattedMessage
id="learning.accessExpiration.header" id="learning.accessExpiration.header"

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n'; import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@edx/paragon'; import { Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages'; import messages from './messages';
function AccessExpirationAlertMMP2P({ payload }) { function AccessExpirationAlertMMP2P({ payload }) {
@@ -52,7 +52,7 @@ function AccessExpirationAlertMMP2P({ payload }) {
} }
return ( return (
<Alert variant="info" icon={Info}> <Alert type={ALERT_TYPES.INFO}>
<span className="font-weight-bold"> <span className="font-weight-bold">
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')} Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
</span> </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 React, { useMemo } from 'react';
import { useAlert } from '../../generic/user-messages'; import { useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert')); const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) { function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it. const isVisible = !!accessExpiration; // If it exists, show it.
const payload = { const payload = {
accessExpiration, accessExpiration,
courseId, courseId,
@@ -24,28 +22,4 @@ function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone,
return { clientAccessExpirationAlert: AccessExpirationAlert }; return { clientAccessExpirationAlert: AccessExpirationAlert };
} }
export function useAccessExpirationMasqueradeBanner(courseId, tab) {
const {
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
accessExpiration,
} = useModel(tab, courseId);
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
const expirationDate = accessExpiration && accessExpiration.expirationDate;
const payload = {
expirationDate,
userTimezone,
};
useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner',
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
});
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
}
export default useAccessExpirationAlert; export default useAccessExpirationAlert;

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,14 +10,4 @@ Factory.define('courseHomeMetadata')
is_self_paced: false, is_self_paced: false,
is_enrolled: false, is_enrolled: false,
can_load_courseware: 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, has_ended: false,
learner_is_full_access: true, learner_is_full_access: true,
user_timezone: 'America/New_York',
}); });

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
// This set of data may not be realistic, but it is intended to demonstrate many UI results. // This set of data may not be realistic, but it is intended to demonstrate many UI results.
Factory.define('progressTabData') Factory.define('progressTabData')
.attrs({ .attrs({
access_expiration: null,
end: '3027-03-31T00:00:00Z', end: '3027-03-31T00:00:00Z',
certificate_data: {}, certificate_data: {},
completion_summary: { completion_summary: {
@@ -25,12 +24,10 @@ Factory.define('progressTabData')
assignment_type: 'Homework', assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection', display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 0, num_points_earned: 0,
num_points_possible: 3, num_points_possible: 1,
percent_graded: 0.0, percent_graded: 0.0,
problem_scores: [{ earned: 0, possible: 1 }, { earned: 0, possible: 1 }, { earned: 0, possible: 1 }],
show_correctness: 'always', show_correctness: 'always',
show_grades: true, show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection', url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
@@ -47,7 +44,6 @@ Factory.define('progressTabData')
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 1, num_points_possible: 1,
percent_graded: 1.0, percent_graded: 1.0,
problem_scores: [{ earned: 1, possible: 1 }],
show_correctness: 'always', show_correctness: 'always',
show_grades: true, show_grades: true,
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection', url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',

View File

@@ -1,6 +1,6 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('upgradeNotificationData') Factory.define('upgradeCardData')
.option('host', 'http://localhost:18000') .option('host', 'http://localhost:18000')
.option('dateBlocks', []) .option('dateBlocks', [])
.option('offer', null) .option('offer', null)
@@ -8,7 +8,6 @@ Factory.define('upgradeNotificationData')
.option('accessExpiration', null) .option('accessExpiration', null)
.option('contentTypeGatingEnabled', false) .option('contentTypeGatingEnabled', false)
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course') .attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
.attr('upsellPageName', 'test')
.attr('verifiedMode', ['host'], (host) => ({ .attr('verifiedMode', ['host'], (host) => ({
access_expiration_date: '2050-01-01T12:00:00', access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD', currency: 'USD',

View File

@@ -5,7 +5,6 @@ Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
"toastHeader": "", "toastHeader": "",
@@ -13,30 +12,22 @@ Object {
"courseware": Object { "courseware": Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null, "sequenceId": null,
"sequenceStatus": "loading", "sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false, "canLoadCourseware": false,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1", "id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
"isStaff": false, "isStaff": false,
"number": "DemoX", "number": "DemoX",
"org": "edX", "org": "edX",
"originalUserIsStaff": false, "originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [ "tabs": Array [
Object { Object {
"slug": "outline", "slug": "outline",
@@ -70,7 +61,6 @@ Object {
}, },
], ],
"title": "Demonstration Course", "title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object { "verifiedMode": Object {
"currencySymbol": "$", "currencySymbol": "$",
"price": 10, "price": 10,
@@ -295,6 +285,7 @@ Object {
"hasEnded": false, "hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course_1", "id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true, "learnerIsFullAccess": true,
"userTimezone": "America/New_York",
}, },
}, },
}, },
@@ -309,7 +300,6 @@ Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
"toastHeader": "", "toastHeader": "",
@@ -317,30 +307,22 @@ Object {
"courseware": Object { "courseware": Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null, "sequenceId": null,
"sequenceStatus": "loading", "sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false, "canLoadCourseware": false,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1", "id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
"isStaff": false, "isStaff": false,
"number": "DemoX", "number": "DemoX",
"org": "edX", "org": "edX",
"originalUserIsStaff": false, "originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [ "tabs": Array [
Object { Object {
"slug": "outline", "slug": "outline",
@@ -374,7 +356,6 @@ Object {
}, },
], ],
"title": "Demonstration Course", "title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object { "verifiedMode": Object {
"currencySymbol": "$", "currencySymbol": "$",
"price": 10, "price": 10,
@@ -395,7 +376,8 @@ Object {
"courseBlocks": Object { "courseBlocks": Object {
"courses": Object { "courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object { "block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"hasScheduledContent": false, "effortActivities": undefined,
"effortTime": undefined,
"id": "course-v1:edX+DemoX+Demo_Course_1", "id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [ "sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
@@ -407,6 +389,8 @@ Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object { "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"complete": false, "complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"effortActivities": 2,
"effortTime": 15,
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false, "resumeBlock": false,
"sequenceIds": Array [ "sequenceIds": Array [
@@ -420,8 +404,8 @@ Object {
"complete": false, "complete": false,
"description": null, "description": null,
"due": null, "due": null,
"effortActivities": 2, "effortActivities": undefined,
"effortTime": 15, "effortTime": undefined,
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy", "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 +425,11 @@ Object {
"title": "Bookmarks", "title": "Bookmarks",
"url": "https://example.com/bookmarks", "url": "https://example.com/bookmarks",
}, },
Object {
"analyticsId": "edx.tool.verified_upgrade",
"title": "Upgrade to Verified",
"url": "https://example.com/upgrade",
},
], ],
"datesBannerInfo": Object { "datesBannerInfo": Object {
"contentTypeGatingEnabled": false, "contentTypeGatingEnabled": false,
@@ -449,15 +438,14 @@ Object {
}, },
"datesWidget": Object { "datesWidget": Object {
"courseDateBlocks": Array [], "courseDateBlocks": Array [],
"userTimezone": "UTC",
}, },
"enrollAlert": Object { "enrollAlert": Object {
"canEnroll": true, "canEnroll": true,
"extraText": "Contact the administrator.", "extraText": "Contact the administrator.",
}, },
"enrollmentMode": undefined,
"handoutsHtml": "<ul><li>Handout 1</li></ul>", "handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined, "hasEnded": undefined,
"hasScheduledContent": null,
"id": "course-v1:edX+DemoX+Demo_Course_1", "id": "course-v1:edX+DemoX+Demo_Course_1",
"offer": null, "offer": null,
"resumeCourse": Object { "resumeCourse": Object {
@@ -465,7 +453,6 @@ Object {
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
}, },
"timeOffsetMillis": 0, "timeOffsetMillis": 0,
"userHasPassingGrade": undefined,
"verifiedMode": Object { "verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00", "accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD", "currency": "USD",
@@ -489,7 +476,6 @@ Object {
"courseHome": Object { "courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded", "courseStatus": "loaded",
"targetUserId": undefined,
"toastBodyLink": null, "toastBodyLink": null,
"toastBodyText": null, "toastBodyText": null,
"toastHeader": "", "toastHeader": "",
@@ -497,30 +483,22 @@ Object {
"courseware": Object { "courseware": Object {
"courseId": null, "courseId": null,
"courseStatus": "loading", "courseStatus": "loading",
"proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null, "sequenceId": null,
"sequenceStatus": "loading", "sequenceStatus": "loading",
"specialExamsEnabledWaffleFlag": false,
}, },
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": false, "canLoadCourseware": false,
"courseAccess": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"id": "course-v1:edX+DemoX+Demo_Course_1", "id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false, "isEnrolled": false,
"isMasquerading": false,
"isSelfPaced": false, "isSelfPaced": false,
"isStaff": false, "isStaff": false,
"number": "DemoX", "number": "DemoX",
"org": "edX", "org": "edX",
"originalUserIsStaff": false, "originalUserIsStaff": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [ "tabs": Array [
Object { Object {
"slug": "outline", "slug": "outline",
@@ -554,7 +532,6 @@ Object {
}, },
], ],
"title": "Demonstration Course", "title": "Demonstration Course",
"userTimezone": "UTC",
"verifiedMode": Object { "verifiedMode": Object {
"currencySymbol": "$", "currencySymbol": "$",
"price": 10, "price": 10,
@@ -564,7 +541,6 @@ Object {
}, },
"progress": Object { "progress": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object { "course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"certificateData": Object {}, "certificateData": Object {},
"completionSummary": Object { "completionSummary": Object {
"completeCount": 1, "completeCount": 1,
@@ -580,8 +556,6 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1", "courseId": "course-v1:edX+DemoX+Demo_Course_1",
"end": "3027-03-31T00:00:00Z", "end": "3027-03-31T00:00:00Z",
"enrollmentMode": "audit", "enrollmentMode": "audit",
"gradesFeatureIsFullyLocked": false,
"gradesFeatureIsPartiallyLocked": false,
"gradingPolicy": Object { "gradingPolicy": Object {
"assignmentPolicies": Array [ "assignmentPolicies": Array [
Object { Object {
@@ -608,24 +582,9 @@ Object {
"blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345", "blockKey": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345",
"displayName": "First subsection", "displayName": "First subsection",
"hasGradedAssignment": true, "hasGradedAssignment": true,
"learnerHasAccess": true,
"numPointsEarned": 0, "numPointsEarned": 0,
"numPointsPossible": 3, "numPointsPossible": 1,
"percentGraded": 0, "percentGraded": 0,
"problemScores": Array [
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
Object {
"earned": 0,
"possible": 1,
},
],
"showCorrectness": "always", "showCorrectness": "always",
"showGrades": true, "showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection", "url": "http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection",
@@ -642,12 +601,6 @@ Object {
"numPointsEarned": 1, "numPointsEarned": 1,
"numPointsPossible": 1, "numPointsPossible": 1,
"percentGraded": 1, "percentGraded": 1,
"problemScores": Array [
Object {
"earned": 1,
"possible": 1,
},
],
"showCorrectness": "always", "showCorrectness": "always",
"showGrades": true, "showGrades": true,
"url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection", "url": "http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection",

View File

@@ -98,7 +98,6 @@ function normalizeCourseHomeCourseMetadata(metadata) {
title: tab.title, title: tab.title,
url: tab.url, url: tab.url,
})), })),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
}; };
} }
@@ -112,16 +111,19 @@ export function normalizeOutlineBlocks(courseId, blocks) {
switch (block.type) { switch (block.type) {
case 'course': case 'course':
models.courses[block.id] = { models.courses[block.id] = {
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: courseId, id: courseId,
title: block.display_name, title: block.display_name,
sectionIds: block.children || [], sectionIds: block.children || [],
hasScheduledContent: block.has_scheduled_content,
}; };
break; break;
case 'chapter': case 'chapter':
models.sections[block.id] = { models.sections[block.id] = {
complete: block.complete, complete: block.complete,
effortActivities: block.effort_activities,
effortTime: block.effort_time,
id: block.id, id: block.id,
title: block.display_name, title: block.display_name,
resumeBlock: block.resume_block, resumeBlock: block.resume_block,
@@ -180,7 +182,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
} }
export async function getCourseHomeCourseMetadata(courseId) { export async function getCourseHomeCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
url = appendBrowserTimezoneToUrl(url); url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data); return normalizeCourseHomeCourseMetadata(data);
@@ -192,7 +194,7 @@ export async function getCourseHomeCourseMetadata(courseId) {
// import './__factories__'; // import './__factories__';
export async function getDatesTabData(courseId) { export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData')); // return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/dates/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try { try {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data); return camelCaseObject(data);
@@ -200,26 +202,20 @@ export async function getDatesTabData(courseId) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) { if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`); global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
} }
// 401 can be returned for unauthenticated users or users who are not enrolled
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
// courseAccess in the metadata call, so just ignore this status for now.
return {};
} }
throw error; throw error;
} }
} }
export async function getProgressTabData(courseId, targetUserId) { export async function getProgressTabData(courseId, userId) {
let url = `${getConfig().LMS_BASE_URL}/api/course_home/progress/${courseId}`; let url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
if (userId) {
// If targetUserId is passed in, we will get the progress page data url += `/${userId}/`;
// for the user with the provided id, rather than the requesting user.
if (targetUserId) {
url += `/${targetUserId}/`;
} }
try { try {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data); const camelCasedData = camelCaseObject(data);
@@ -244,44 +240,22 @@ export async function getProgressTabData(courseId, targetUserId) {
// in order to preserve a course team's desired grade formatting. // in order to preserve a course team's desired grade formatting.
camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range; 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 camelCasedData;
} catch (error) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) { if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`); global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
} }
// 401 can be returned for unauthenticated users or users who are not enrolled
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining global.location.replace(`${getConfig().BASE_URL}/course/${courseId}/home`);
// courseAccess in the metadata call, so just ignore this status for now.
return {};
} }
throw error; throw error;
} }
} }
export async function getProctoringInfoData(courseId, username) { export async function getProctoringInfoData(courseId, username) {
let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`; let url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
if (username) { if (username) {
url += `&username=${encodeURIComponent(username)}`; url += `&username=${encodeURIComponent(username)}`;
} }
@@ -313,7 +287,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
} }
export async function getOutlineTabData(courseId) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {}; let { tabData } = {};
let requestTime = Date.now(); let requestTime = Date.now();
let responseTime = requestTime; let responseTime = requestTime;
@@ -344,14 +318,11 @@ export async function getOutlineTabData(courseId) {
const datesBannerInfo = camelCaseObject(data.dates_banner_info); const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget); const datesWidget = camelCaseObject(data.dates_widget);
const enrollAlert = camelCaseObject(data.enroll_alert); const enrollAlert = camelCaseObject(data.enroll_alert);
const enrollmentMode = data.enrollment_mode;
const handoutsHtml = data.handouts_html; const handoutsHtml = data.handouts_html;
const hasScheduledContent = data.has_scheduled_content;
const hasEnded = data.has_ended; const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer); const offer = camelCaseObject(data.offer);
const resumeCourse = camelCaseObject(data.resume_course); const resumeCourse = camelCaseObject(data.resume_course);
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime); const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode); const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html; const welcomeMessageHtml = data.welcome_message_html;
@@ -365,14 +336,11 @@ export async function getOutlineTabData(courseId) {
datesBannerInfo, datesBannerInfo,
datesWidget, datesWidget,
enrollAlert, enrollAlert,
enrollmentMode,
handoutsHtml, handoutsHtml,
hasScheduledContent,
hasEnded, hasEnded,
offer, offer,
resumeCourse, resumeCourse,
timeOffsetMillis, // This should move to a global time correction reference timeOffsetMillis, // This should move to a global time correction reference
userHasPassingGrade,
verifiedMode, verifiedMode,
welcomeMessageHtml, welcomeMessageHtml,
}; };
@@ -387,12 +355,12 @@ export async function postCourseDeadlines(courseId, model) {
} }
export async function postCourseGoals(courseId, goalKey) { export async function postCourseGoals(courseId, goalKey) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`); const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`);
return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey }); return getAuthenticatedHttpClient().post(url.href, { course_id: courseId, goal_key: goalKey });
} }
export async function postDismissWelcomeMessage(courseId) { export async function postDismissWelcomeMessage(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/dismiss_welcome_message`); const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dismiss_welcome_message`);
await getAuthenticatedHttpClient().post(url.href, { course_id: courseId }); await getAuthenticatedHttpClient().post(url.href, { course_id: courseId });
} }
@@ -408,9 +376,3 @@ export async function executePostFromPostEvent(postData, researchEventData) {
research_event_data: researchEventData, research_event_data: researchEventData,
}); });
} }
export async function unsubscribeFromCourseGoal(token) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_home/unsubscribe_from_course_goal/${token}`);
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ import {
} from '../../generic/model-store'; } from '../../generic/model-store';
import { import {
fetchTabDenied,
fetchTabFailure, fetchTabFailure,
fetchTabRequest, fetchTabRequest,
fetchTabSuccess, fetchTabSuccess,
@@ -28,12 +27,12 @@ const eventTypes = {
POST_EVENT: 'post_event', POST_EVENT: 'post_event',
}; };
export function fetchTab(courseId, tab, getTabData, targetUserId) { export function fetchTab(courseId, tab, getTabData, userId) {
return async (dispatch) => { return async (dispatch) => {
dispatch(fetchTabRequest({ courseId })); dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([ Promise.allSettled([
getCourseHomeCourseMetadata(courseId), getCourseHomeCourseMetadata(courseId),
getTabData(courseId, targetUserId), getTabData(courseId, userId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => { ]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled'; const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled'; const fetchedTabData = tabDataResult.status === 'fulfilled';
@@ -62,11 +61,8 @@ export function fetchTab(courseId, tab, getTabData, targetUserId) {
logError(tabDataResult.reason); logError(tabDataResult.reason);
} }
// Disable the access-denied path for now - it caused a regression if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) { dispatch(fetchTabSuccess({ courseId }));
dispatch(fetchTabDenied({ courseId }));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId }));
} else { } else {
dispatch(fetchTabFailure({ courseId })); dispatch(fetchTabFailure({ courseId }));
} }
@@ -78,8 +74,8 @@ export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData); return fetchTab(courseId, 'dates', getDatesTabData);
} }
export function fetchProgressTab(courseId, targetUserId) { export function fetchProgressTab(courseId, userId) {
return fetchTab(courseId, 'progress', getProgressTabData, parseInt(targetUserId, 10) || targetUserId); return fetchTab(courseId, 'progress', getProgressTabData, userId);
} }
export function fetchOutlineTab(courseId) { 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,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default function Badge({ children, className, ...rest }) {
return (
<span
className={classNames('dates-badge x-small ml-2 position-absolute', className)}
data-testid="dates-badge"
{...rest}
>
{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: 1px 8px;
}

View File

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

View File

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

@@ -12,10 +12,10 @@ import { Tooltip, OverlayTrigger } from '@edx/paragon';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../generic/model-store';
import { getBadgeListAndColor } from './badgelist'; import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from '../utils'; import { isLearnerAssignment } from './utils';
function Day({ function Day({
date, date,
@@ -29,10 +29,10 @@ function Day({
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
userTimezone, userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('dates', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const { color, badges } = getBadgeListAndColor(date, intl, null, items); const { color, badges } = getBadgeListAndColor(date, intl, null, items);
@@ -55,16 +55,18 @@ function Day({
{/* Content */} {/* Content */}
<div className="d-inline-block ml-3 pl-2"> <div className="d-inline-block ml-3 pl-2">
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header"> <div className="mb-1" data-testid="dates-header">
<FormattedDate <p className="d-inline text-dark-400">
/** [MM-P2P] Experiment */ <FormattedDate
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date} /** [MM-P2P] Experiment */
day="numeric" value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
month="short" day="numeric"
weekday="short" month="short"
year="numeric" weekday="short"
{...timezoneFormatArgs} year="numeric"
/> {...timezoneFormatArgs}
/>
</p>
{badges} {badges}
</div> </div>
{items.map((item) => { {items.map((item) => {
@@ -80,7 +82,7 @@ function Day({
const textColor = available ? 'text-primary-700' : 'text-gray-500'; const textColor = available ? 'text-primary-700' : 'text-gray-500';
return ( return (
<div key={item.title + item.date} className={classNames(textColor, 'small pb-1')} data-testid="dates-item"> <div key={item.title + item.date} className={classNames(textColor, 'small')} data-testid="dates-item">
<div> <div>
<span className="small"> <span className="small">
<span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span> <span className="font-weight-bold">{item.assignmentType && `${item.assignmentType}: `}{title}</span>

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import PageLoading from '../../generic/PageLoading';
import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState({});
// We don't need to bother with redux for this simple page. We're not sharing state with other pages at all.
useEffect(() => {
unsubscribeFromCourseGoal(token)
.then(
(result) => {
setIsLoading(false);
setData(result.data);
},
() => {
setIsLoading(false);
setError(true);
},
);
}, []); // 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

@@ -13,7 +13,7 @@ export default function LmsHtmlFragment({
<html> <html>
<head> <head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent"> <base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css"> <link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css"> <link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
</head> </head>
<body class="${className}">${html}</body> <body class="${className}">${html}</body>

View File

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

View File

@@ -16,7 +16,6 @@ import * as thunks from '../data/thunks';
import initializeStore from '../../store'; import initializeStore from '../../store';
import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert'; import { CERT_STATUS_TYPE } from './alerts/certificate-status-alert/CertificateStatusAlert';
import OutlineTab from './OutlineTab'; import OutlineTab from './OutlineTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
@@ -25,13 +24,12 @@ describe('Outline Tab', () => {
let axiosMock; let axiosMock;
const courseId = 'course-v1:edX+Test+run'; const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`; const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`; const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?is_learning_mfe=true&course_id=${encodeURIComponent(courseId)}`;
const store = initializeStore(); const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId }); const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
@@ -59,7 +57,6 @@ describe('Outline Tab', () => {
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {}); axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' }); axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData); axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, { axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'created', onboarding_status: 'created',
@@ -163,9 +160,9 @@ describe('Outline Tab', () => {
}); });
}); });
describe('Suggested schedule alerts', () => { describe('Dates Banner', () => {
beforeEach(() => { beforeEach(() => {
setMetadata({ is_enrolled: true, is_self_paced: true }); setMetadata({ is_enrolled: true });
setTabData({ setTabData({
dates_banner_info: { dates_banner_info: {
content_type_gating_enabled: true, content_type_gating_enabled: true,
@@ -188,15 +185,15 @@ describe('Outline Tab', () => {
}); });
}); });
it('renders UpgradeToShiftDatesAlert', async () => { it('renders upgradeToReset', async () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument(); expect(screen.getByText('You are auditing this course,')).toBeInTheDocument();
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.')).toBeInTheDocument(); expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
}); });
it('sends analytics event onClick of upgrade button in UpgradeToShiftDatesAlert', async () => { it('sends analytics event onClick of upgrade button in banner', async () => {
await fetchAndRender(); await fetchAndRender();
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
@@ -440,6 +437,35 @@ describe('Outline Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Course Tools' })).not.toBeInTheDocument();
}); });
it('analytics sent when upgrade link clicked', async () => {
await fetchAndRender();
expect(screen.getByRole('heading', { name: 'Course Tools' })).toBeInTheDocument();
sendTrackEvent.mockClear();
sendTrackingLogEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade to Verified' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: '(none)',
linkName: 'course_home_course_tools',
linkType: 'link',
pageName: 'course_home',
});
expect(sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.tool.accessed', {
org_key: 'edX',
courserun_key: courseId,
course_id: courseId,
is_staff: false,
tool_name: 'edx.tool.verified_upgrade',
});
});
}); });
describe('Alert List', () => { describe('Alert List', () => {
@@ -459,8 +485,8 @@ describe('Outline Tab', () => {
}); });
await fetchAndRender(); await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert'); const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert).toHaveAttribute('role', 'alert'); expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument(); expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
@@ -469,8 +495,8 @@ describe('Outline Tab', () => {
it('displays alert for unenrolled user', async () => { it('displays alert for unenrolled user', async () => {
await fetchAndRender(); await fetchAndRender();
const alert = await screen.findByTestId('private-course-alert'); const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert).toHaveAttribute('role', 'alert'); expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
}); });
@@ -495,35 +521,70 @@ describe('Outline Tab', () => {
}); });
describe('Access Expiration Alert', () => { describe('Access Expiration Alert', () => {
it('renders page banner on masquerade', async () => { it('has special masquerade text', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({ setTabData({
access_expiration: { access_expiration: {
expiration_date: '2020-01-01T12:00:00Z', expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true, masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
}, },
}); });
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await fetchAndRender();
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store })); await screen.findByText('This learner does not have access to this course.', { exact: false });
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
}); });
it('does not render banner when not masquerading', async () => { it('shows expiration', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
setTabData({ setTabData({
access_expiration: { access_expiration: {
expiration_date: '2020-01-01T12:00:00Z', expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false, masquerading_expired_course: false,
upgrade_deadline: null,
upgrade_url: null,
}, },
}); });
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await fetchAndRender();
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="outline">...</LoadedTabPage>, { store })); await screen.findByText('Audit Access Expires');
const instructorToolbar = await screen.getByTestId('instructor-toolbar'); });
expect(instructorToolbar).toBeInTheDocument();
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument(); it('shows upgrade prompt', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
});
it('sends analytics event onClick of upgrade link', async () => {
setTabData({
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
masquerading_expired_course: false,
upgrade_deadline: '2070-01-01T12:00:00Z',
upgrade_url: 'https://example.com/upgrade',
},
});
await fetchAndRender();
// Clearing after render to remove any events sent on view (ex. 'Promotion Viewed')
sendTrackEvent.mockClear();
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: courseId,
linkCategory: 'FBE_banner',
linkName: 'course_home_audit_access_expires',
linkType: 'link',
pageName: 'course_home',
});
}); });
}); });
@@ -532,7 +593,16 @@ describe('Outline Tab', () => {
it('appears several days out', async () => { it('appears several days out', async () => {
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() + 100); startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true, start: '2999-01-01T00:00:00Z' }); setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender(); await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false }); const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
@@ -541,7 +611,16 @@ describe('Outline Tab', () => {
it('appears today', async () => { it('appears today', async () => {
const startDate = new Date(); const startDate = new Date();
startDate.setHours(startDate.getHours() + 1); startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true, start: startDate }); setMetadata({ is_enrolled: true });
setTabData({}, {
date_blocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender(); await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false }); const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
@@ -618,247 +697,6 @@ describe('Outline Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument(); expect(screen.queryByText('Your grade and certificate will be ready soon!')).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 });
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();
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 () => {
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);
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',
},
],
});
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();
});
}); });
}); });
@@ -888,33 +726,6 @@ describe('Outline Tab', () => {
}); });
}); });
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', () => { describe('Certificate (pdf) Complete Alert', () => {
it('appears', async () => { it('appears', async () => {
const now = new Date(); const now = new Date();

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 { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import EffortEstimate from '../../shared/effort-estimate';
import SequenceLink from './SequenceLink'; import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store'; import { useModel } from '../../generic/model-store';
@@ -66,6 +67,7 @@ function Section({
<span className="sr-only"> <span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)} , {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span> </span>
<EffortEstimate className="ml-3 align-middle" block={section} />
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -7,192 +7,85 @@ import {
intlShape, intlShape,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@edx/paragon'; import { Alert, Button } from '@edx/paragon';
import { useDispatch } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheckCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { faCheckCircle } 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 certMessages from './messages';
import certStatusMessages from '../../../progress-tab/certificate-status/messages'; import certStatusMessages from '../../../progress-tab/certificate-status/messages';
import { requestCert } from '../../../data/thunks';
export const CERT_STATUS_TYPE = { export const CERT_STATUS_TYPE = {
EARNED_NOT_AVAILABLE: 'earned_but_not_available', EARNED_NOT_AVAILABLE: 'earned_but_not_available',
DOWNLOADABLE: 'downloadable', DOWNLOADABLE: 'downloadable',
REQUESTING: 'requesting',
UNVERIFIED: 'unverified',
}; };
function CertificateStatusAlert({ intl, payload }) { function CertificateStatusAlert({ intl, payload }) {
const dispatch = useDispatch();
const { const {
certificateAvailableDate, certificateAvailableDate,
certStatus, certStatusType,
courseEndDate, courseEndDate,
courseId,
certURL, certURL,
isWebCert, isWebCert,
userTimezone, userTimezone,
org,
notPassingCourseEnded,
tabs,
} = payload; } = payload;
// eslint-disable-next-line react/prop-types let variant = '';
const AlertWrapper = (props) => props.children(props); if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE || certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) {
variant = 'success';
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 ( let header = '';
<AlertWrapper {...alertProps}> let body = '';
{({ let buttonVisible = false;
variant, let buttonMessage = '';
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>
)} if (certStatusType === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
</AlertWrapper> 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" />;
header = intl.formatMessage(certMessages.certStatusEarnedNotAvailableHeader);
body = (
<p>
<FormattedMessage
id="learning.outline.alert.cert.when"
defaultMessage="This course ended on {courseEndDateFormatted}. Final grades and certificates are
scheduled to be available after {certificateAvailableDate}."
values={{
courseEndDateFormatted,
certificateAvailableDate: certificateAvailableDateFormatted,
}}
{...timezoneFormatArgs}
/>
</p>
);
} else if (certStatusType === CERT_STATUS_TYPE.DOWNLOADABLE) {
header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
if (isWebCert) {
buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
} else {
buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
}
buttonVisible = true;
}
return (
<Alert variant={variant}>
<div className="row justify-content-between align-items-center">
<div className={buttonVisible ? '' : 'col-auto'}>
<FontAwesomeIcon icon={faCheckCircle} className="alert-icon text-success-500" />
<Alert.Heading>{header}</Alert.Heading>
{body}
</div>
{buttonVisible && (
<div className="m-auto m-lg-0 pr-lg-3">
<Button
variant="primary"
href={certURL}
>
{buttonMessage}
</Button>
</div>
)}
</div>
</Alert>
); );
} }
@@ -200,19 +93,11 @@ CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,
payload: PropTypes.shape({ payload: PropTypes.shape({
certificateAvailableDate: PropTypes.string, certificateAvailableDate: PropTypes.string,
certStatus: PropTypes.string, certStatusType: PropTypes.string,
courseEndDate: PropTypes.string, courseEndDate: PropTypes.string,
courseId: PropTypes.string,
certURL: PropTypes.string, certURL: PropTypes.string,
isWebCert: PropTypes.bool, isWebCert: PropTypes.bool,
userTimezone: PropTypes.string, 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, }).isRequired,
}; };

View File

@@ -3,48 +3,32 @@ import React, { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useAlert } from '../../../../generic/user-messages'; import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import { CERT_STATUS_TYPE } from './CertificateStatusAlert'; import { CERT_STATUS_TYPE } from './CertificateStatusAlert';
const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert')); const CertificateStatusAlert = React.lazy(() => import('./CertificateStatusAlert'));
function verifyCertStatusType(status) { function verifyCertStatusType(status) {
switch (status) { // This method will only return cert statuses when we want to alert on them.
case CERT_STATUS_TYPE.DOWNLOADABLE: // It should be modified when we want to alert on a new status type.
case CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE: if (status === CERT_STATUS_TYPE.DOWNLOADABLE) {
case CERT_STATUS_TYPE.REQUESTING: return CERT_STATUS_TYPE.DOWNLOADABLE;
case CERT_STATUS_TYPE.UNVERIFIED:
return true;
default:
return false;
} }
if (status === CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE) {
return CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE;
}
return '';
} }
function useCertificateStatusAlert(courseId) { 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 { const {
isEnrolled, isEnrolled,
org,
tabs,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
datesWidget: { datesWidget: {
courseDateBlocks, courseDateBlocks,
userTimezone,
}, },
certData, certData,
hasEnded,
userHasPassingGrade,
userTimezone,
enrollmentMode,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const { const {
@@ -53,13 +37,12 @@ function useCertificateStatusAlert(courseId) {
certificateAvailableDate, certificateAvailableDate,
downloadUrl, downloadUrl,
} = certData || {}; } = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date'); const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const certStatusType = verifyCertStatusType(certStatus);
const isWebCert = downloadUrl === null; const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& !!Object.values(VERIFIED_MODES).find(mode => mode === enrollmentMode)
);
let certURL = ''; let certURL = '';
if (certWebViewUrl) { if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
@@ -67,33 +50,20 @@ function useCertificateStatusAlert(courseId) {
// PDF Certificate // PDF Certificate
certURL = downloadUrl; certURL = downloadUrl;
} }
const hasAlertingCertStatus = verifyCertStatusType(certStatus); const hasCertStatus = certStatusType !== '';
// Only show if: // Only show if there is a known cert status that we want provide status on.
// - there is a known cert status that we want provide status on. const isVisible = isEnrolled && hasCertStatus;
// - 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 = { const payload = {
certificateAvailableDate, certificateAvailableDate,
certURL, certURL,
certStatus, certStatusType,
courseId,
courseEndDate: endBlock && endBlock.date, courseEndDate: endBlock && endBlock.date,
userTimezone, userTimezone,
isWebCert, isWebCert,
org,
notPassingCourseEnded,
tabs,
}; };
useAlert(isVisible || notPassingCourseEnded, { useAlert(isVisible, {
code: 'clientCertificateStatusAlert', code: 'clientCertificateStatusAlert',
payload: useMemo(() => payload, Object.values(payload).sort()), payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts', topic: 'outline-course-alerts',

View File

@@ -11,14 +11,6 @@ const messages = defineMessages({
defaultMessage: 'Congratulations! Your certificate is ready.', defaultMessage: 'Congratulations! Your certificate is ready.',
description: 'Header alerting the user that their 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; export default messages;

View File

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

View File

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

View File

@@ -6,22 +6,16 @@ import {
FormattedRelative, FormattedRelative,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } 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 const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) { function CourseStartAlert({ payload }) {
const { const {
courseId, startDate,
} = payload;
const {
start: startDate,
userTimezone, userTimezone,
} = useModel('courseHomeMeta', courseId); } = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
@@ -36,7 +30,7 @@ function CourseStartAlert({ payload }) {
const delta = new Date(startDate) - new Date(); const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) { if (delta < DAY_MS) {
return ( return (
<Alert variant="info" icon={Info}> <Alert type={ALERT_TYPES.INFO}>
<FormattedMessage <FormattedMessage
id="learning.outline.alert.start.short" id="learning.outline.alert.start.short"
defaultMessage="Course starts {timeRemaining} at {courseStartTime}." defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
@@ -61,7 +55,7 @@ function CourseStartAlert({ payload }) {
} }
return ( return (
<Alert variant="info" icon={Info}> <Alert type={ALERT_TYPES.INFO}>
<strong> <strong>
<FormattedMessage <FormattedMessage
id="learning.outline.alert.end.long" id="learning.outline.alert.end.long"
@@ -93,7 +87,8 @@ function CourseStartAlert({ payload }) {
CourseStartAlert.propTypes = { CourseStartAlert.propTypes = {
payload: PropTypes.shape({ payload: PropTypes.shape({
courseId: PropTypes.string, startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired, }).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 { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth'; import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Alert, Button, Hyperlink } from '@edx/paragon'; import { Button, Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { Alert } from '../../../../generic/user-messages';
import enrollmentMessages from '../../../../alerts/enrollment-alert/messages'; import enrollmentMessages from '../../../../alerts/enrollment-alert/messages';
import genericMessages from '../../../../generic/messages'; import genericMessages from '../../../../generic/messages';
import messages from './messages';
import outlineMessages 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'; import { useModel } from '../../../../generic/model-store';
function PrivateCourseAlert({ intl, payload }) { function PrivateCourseAlert({ intl, payload }) {
@@ -32,13 +32,12 @@ function PrivateCourseAlert({ intl, payload }) {
intl.formatMessage(enrollmentMessages.success), intl.formatMessage(enrollmentMessages.success),
); );
const enrollNowButton = ( const enrollNow = (
<Button <Button
disabled={loading} disabled={loading}
variant="link" variant="link"
className="p-0 border-0 align-top mr-1" className="p-0 border-0 align-top"
style={{ textDecoration: 'underline' }} style={{ textDecoration: 'underline' }}
size="sm"
onClick={enrollClickHandler} onClick={enrollClickHandler}
> >
{intl.formatMessage(enrollmentMessages.enrollNowInline)} {intl.formatMessage(enrollmentMessages.enrollNowInline)}
@@ -64,7 +63,7 @@ function PrivateCourseAlert({ intl, payload }) {
); );
return ( return (
<Alert variant="light" data-testid="private-course-alert"> <Alert type="welcome">
{anonymousUser && ( {anonymousUser && (
<> <>
<p className="font-weight-bold"> <p className="font-weight-bold">
@@ -85,11 +84,15 @@ function PrivateCourseAlert({ intl, payload }) {
<> <>
<p className="font-weight-bold">{intl.formatMessage(outlineMessages.welcomeTo)} {title}</p> <p className="font-weight-bold">{intl.formatMessage(outlineMessages.welcomeTo)} {title}</p>
{canEnroll && ( {canEnroll && (
<div className="d-flex"> <>
{enrollNowButton} <FormattedMessage
{intl.formatMessage(messages.toAccess)} 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 />} {loading && <FontAwesomeIcon icon={faSpinner} spin />}
</div> </>
)} )}
{!canEnroll && ( {!canEnroll && (
<> <>

View File

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

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

@@ -216,10 +216,6 @@ const messages = defineMessages({
id: 'learning.proctoringPanel.reviewRequirementsButton', id: 'learning.proctoringPanel.reviewRequirementsButton',
defaultMessage: 'Review instructions and system requirements', defaultMessage: 'Review instructions and system requirements',
}, },
proctoringOnboardingButtonPastDue: {
id: 'learning.proctoringPanel.onboardingButtonPastDue',
defaultMessage: 'Onboarding Past Due',
},
}); });
export default messages; export default messages;

View File

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

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; 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 { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -36,6 +36,16 @@ function CourseTools({ courseId, intl }) {
is_staff: administrator, is_staff: administrator,
tool_name: analyticsId, 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) => { const renderIcon = (iconClasses) => {

View File

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

View File

@@ -1,40 +1,38 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import { faCheck } from '@fortawesome/free-solid-svg-icons'; import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { setLocalStorage } from '../../data/localStorage';
import { UpgradeButton } from '../upgrade-button'; import { UpgradeButton } from '../../../generic/upgrade-button';
function UpsellNoFBECardContent() { function UpsellNoFBECardContent() {
const verifiedCertLink = ( const verifiedCertLink = (
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}> <a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.verifiedCertLink" id="learning.outline.widgets.upgradeCard.verifiedCertLink"
defaultMessage="verified certificate" defaultMessage="verified certificate"
/> />
</a> </a>
); );
return ( return (
<ul className="fa-ul upgrade-notification-ul pt-0"> <ul className="fa-ul upgrade-card-ul pt-0">
<li> <li>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span> <span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.verifiedCertMessage" id="learning.outline.widgets.upgradeCard.verifiedCertMessage"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resumé" defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
values={{ verifiedCertLink }} values={{ verifiedCertLink }}
/> />
</li> </li>
<li> <li>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span> <span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.noFBE.nonProfitMission" id="learning.outline.widgets.upgradeCard.nonProfitMission"
defaultMessage="Support our {nonProfitMission} at edX" defaultMessage="Support our {nonProfitMission} at edX"
values={{ values={{
nonProfitMission: ( nonProfitMission: (
@@ -51,7 +49,7 @@ function UpsellFBEFarAwayCardContent() {
const verifiedCertLink = ( const verifiedCertLink = (
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}> <a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.verifiedCertLink" id="learning.outline.widgets.upgradeCard.verifiedCertLink"
defaultMessage="verified certificate" defaultMessage="verified certificate"
/> />
</a> </a>
@@ -60,7 +58,7 @@ function UpsellFBEFarAwayCardContent() {
const gradedAssignments = ( const gradedAssignments = (
<span className="font-weight-bold"> <span className="font-weight-bold">
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.gradedAssignments" id="learning.outline.widgets.upgradeCard.gradedAssignments"
defaultMessage="graded assignments" defaultMessage="graded assignments"
/> />
</span> </span>
@@ -69,7 +67,7 @@ function UpsellFBEFarAwayCardContent() {
const fullAccess = ( const fullAccess = (
<span className="font-weight-bold"> <span className="font-weight-bold">
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.verifiedCertLink.fullAccess" id="learning.upgradeCard.verifiedCertLink"
defaultMessage="Full access" defaultMessage="Full access"
/> />
</span> </span>
@@ -78,42 +76,42 @@ function UpsellFBEFarAwayCardContent() {
const nonProfitMission = ( const nonProfitMission = (
<span className="font-weight-bold"> <span className="font-weight-bold">
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.FBE.nonProfitMission" id="learning.upgradeCard.nonProfitMission"
defaultMessage="non-profit mission" defaultMessage="non-profit mission"
/> />
</span> </span>
); );
return ( return (
<ul className="fa-ul upgrade-notification-ul"> <ul className="fa-ul upgrade-card-ul">
<li> <li>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span> <span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.verifiedCertMessage" id="learning.outline.widgets.upgradeCard.verifiedCertMessage"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resumé" defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
values={{ verifiedCertLink }} values={{ verifiedCertLink }}
/> />
</li> </li>
<li> <li>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span> <span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.unlockGraded" id="learning.outline.widgets.upgradeCard.unlockGraded"
defaultMessage="Unlock your access to all course activities, including {gradedAssignments}" defaultMessage="Unlock your access to all course activities, including {gradedAssignments}"
values={{ gradedAssignments }} values={{ gradedAssignments }}
/> />
</li> </li>
<li> <li>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span> <span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.fullAccess" id="learning.outline.widgets.upgradeCard.fullAccess"
defaultMessage="{fullAccess} to course content and materials, even after the course ends" defaultMessage="{fullAccess} to course content and materials, even after the course ends"
values={{ fullAccess }} values={{ fullAccess }}
/> />
</li> </li>
<li> <li>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span> <span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.nonProfitMission" id="learning.outline.widgets.upgradeCard.nonProfitMission"
defaultMessage="Support our {nonProfitMission} at edX" defaultMessage="Support our {nonProfitMission} at edX"
values={{ nonProfitMission }} values={{ nonProfitMission }}
/> />
@@ -126,7 +124,7 @@ function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs })
const includingAnyProgress = ( const includingAnyProgress = (
<span className="font-weight-bold"> <span className="font-weight-bold">
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationAccessLoss.progress" id="learning.upgradeCard.expirationAccessLoss.progress"
defaultMessage="including any progress" defaultMessage="including any progress"
/> />
</span> </span>
@@ -145,17 +143,17 @@ function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs })
const benefitsOfUpgrading = ( const benefitsOfUpgrading = (
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href="https://support.edx.org/hc/en-us/articles/360013426573-What-are-the-differences-between-audit-free-and-verified-paid-courses-"> <a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href="https://support.edx.org/hc/en-us/articles/360013426573-What-are-the-differences-between-audit-free-and-verified-paid-courses-">
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationVerifiedCert.benefits" id="learning.outline.widgets.upgradeCard.expirationVerifiedCert.benefits"
defaultMessage="benefits of upgrading" defaultMessage="benefits of upgrading"
/> />
</a> </a>
); );
return ( return (
<div className="upgrade-notification-text"> <div className="upgrade-card-text">
<p> <p>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationAccessLoss" id="learning.outline.widgets.upgradeCard.expirationAccessLoss"
defaultMessage="You will lose all access to this course, {includingAnyProgress}, on {date}." defaultMessage="You will lose all access to this course, {includingAnyProgress}, on {date}."
values={{ values={{
includingAnyProgress, includingAnyProgress,
@@ -165,7 +163,7 @@ function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs })
</p> </p>
<p> <p>
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationVerifiedCert" id="learning.outline.widgets.upgradeCard.expirationVerifiedCert"
defaultMessage="Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}." defaultMessage="Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}."
values={{ benefitsOfUpgrading }} values={{ benefitsOfUpgrading }}
/> />
@@ -185,23 +183,13 @@ UpsellFBESoonCardContent.defaultProps = {
timezoneFormatArgs: {}, timezoneFormatArgs: {},
}; };
function ExpirationCountdown({ hoursToExpiration, setupgradeNotificationCurrentState, type }) { function ExpirationCountdown({ hoursToExpiration }) {
let expirationText; let expirationText;
if (hoursToExpiration >= 24) { if (hoursToExpiration >= 24) {
// setupgradeNotificationCurrentState is available in NotificationTray (not course home)
if (setupgradeNotificationCurrentState) {
if (type === 'access') {
setupgradeNotificationCurrentState('accessDaysLeft');
setLocalStorage('upgradeNotificationCurrentState', 'accessDaysLeft');
}
if (type === 'offer') {
setupgradeNotificationCurrentState('FPDdaysLeft');
setLocalStorage('upgradeNotificationCurrentState', 'FPDdaysLeft');
}
}
expirationText = ( expirationText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationDays" id="learning.outline.widgets.upgradeCard.expirationDays"
defaultMessage={`{dayCount, number} {dayCount, plural, defaultMessage={`{dayCount, number} {dayCount, plural,
one {day} one {day}
other {days}} left`} other {days}} left`}
@@ -211,20 +199,9 @@ function ExpirationCountdown({ hoursToExpiration, setupgradeNotificationCurrentS
/> />
); );
} else if (hoursToExpiration >= 1) { } else if (hoursToExpiration >= 1) {
// setupgradeNotificationCurrentState is available in NotificationTray (not course home)
if (setupgradeNotificationCurrentState) {
if (type === 'access') {
setupgradeNotificationCurrentState('accessHoursLeft');
setLocalStorage('upgradeNotificationCurrentState', 'accessHoursLeft');
}
if (type === 'offer') {
setupgradeNotificationCurrentState('FPDHoursLeft');
setLocalStorage('upgradeNotificationCurrentState', 'FPDHoursLeft');
}
}
expirationText = ( expirationText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationHours" id="learning.outline.widgets.upgradeCard.expirationHours"
defaultMessage={`{hourCount, number} {hourCount, plural, defaultMessage={`{hourCount, number} {hourCount, plural,
one {hour} one {hour}
other {hours}} left`} other {hours}} left`}
@@ -234,20 +211,9 @@ function ExpirationCountdown({ hoursToExpiration, setupgradeNotificationCurrentS
/> />
); );
} else { } else {
// setupgradeNotificationCurrentState is available in NotificationTray (not course home)
if (setupgradeNotificationCurrentState) {
if (type === 'access') {
setupgradeNotificationCurrentState('accessLastHour');
setLocalStorage('upgradeNotificationCurrentState', 'accessLastHour');
}
if (type === 'offer') {
setupgradeNotificationCurrentState('FPDLastHour');
setLocalStorage('upgradeNotificationCurrentState', 'FPDLastHour');
}
}
expirationText = ( expirationText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expirationMinutes" id="learning.outline.widgets.upgradeCard.expirationMinutes"
defaultMessage="Less than 1 hour left" defaultMessage="Less than 1 hour left"
/> />
); );
@@ -257,23 +223,13 @@ function ExpirationCountdown({ hoursToExpiration, setupgradeNotificationCurrentS
ExpirationCountdown.propTypes = { ExpirationCountdown.propTypes = {
hoursToExpiration: PropTypes.number.isRequired, hoursToExpiration: PropTypes.number.isRequired,
setupgradeNotificationCurrentState: PropTypes.func,
type: PropTypes.string,
};
ExpirationCountdown.defaultProps = {
setupgradeNotificationCurrentState: null,
type: null,
}; };
function AccessExpirationDateBanner({ accessExpirationDate, timezoneFormatArgs, setupgradeNotificationCurrentState }) { function AccessExpirationDateBanner({ accessExpirationDate, timezoneFormatArgs }) {
if (setupgradeNotificationCurrentState) {
setupgradeNotificationCurrentState('accessDateView');
setLocalStorage('upgradeNotificationCurrentState', 'accessDateView');
}
return ( return (
<div className="upsell-warning-light"> <div className="upsell-warning-light">
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.expiration" id="learning.outline.widgets.upgradeCard.expiration"
defaultMessage="Course access will expire {date}" defaultMessage="Course access will expire {date}"
values={{ values={{
date: ( date: (
@@ -296,24 +252,19 @@ AccessExpirationDateBanner.propTypes = {
timezoneFormatArgs: PropTypes.shape({ timezoneFormatArgs: PropTypes.shape({
timeZone: PropTypes.string, timeZone: PropTypes.string,
}), }),
setupgradeNotificationCurrentState: PropTypes.func,
}; };
AccessExpirationDateBanner.defaultProps = { AccessExpirationDateBanner.defaultProps = {
timezoneFormatArgs: {}, timezoneFormatArgs: {},
setupgradeNotificationCurrentState: null,
}; };
function UpgradeNotification({ function UpgradeCard({
accessExpiration, accessExpiration,
contentTypeGatingEnabled, contentTypeGatingEnabled,
courseId, courseId,
offer, offer,
org, org,
setupgradeNotificationCurrentState,
shouldDisplayBorder,
timeOffsetMillis, timeOffsetMillis,
upsellPageName,
userTimezone, userTimezone,
verifiedMode, verifiedMode,
}) { }) {
@@ -352,20 +303,20 @@ function UpgradeNotification({
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', { sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties, ...eventProperties,
linkCategory: 'green_upgrade', linkCategory: 'green_upgrade',
linkName: `${upsellPageName}_green`, linkName: 'course_home_green',
linkType: 'button', linkType: 'button',
pageName: upsellPageName, pageName: 'course_home',
}); });
}; };
/* /*
There are 4 parts that change in the upgrade card: There are 4 parts that change in the upgrade card:
upgradeNotificationHeaderText upgradeCardHeaderText
expirationBanner expirationBanner
upsellMessage upsellMessage
offerCode offerCode
*/ */
let upgradeNotificationHeaderText; let upgradeCardHeaderText;
let expirationBanner; let expirationBanner;
let upsellMessage; let upsellMessage;
let offerCode; let offerCode;
@@ -374,29 +325,37 @@ function UpgradeNotification({
const accessExpirationDate = new Date(accessExpiration.expirationDate); const accessExpirationDate = new Date(accessExpiration.expirationDate);
const hoursToAccessExpiration = Math.floor((accessExpirationDate - correctedTime) / 1000 / 60 / 60); const hoursToAccessExpiration = Math.floor((accessExpirationDate - correctedTime) / 1000 / 60 / 60);
if (offer) { // if there's a first purchase discount, message the code at the bottom
offerCode = (
<div className="text-center discount-info">
<FormattedMessage
id="learning.outline.widgets.upgradeCard.code"
defaultMessage="Use code {code} at checkout"
values={{
code: (<span className="font-weight-bold">{offer.code}</span>),
}}
/>
</div>
);
}
if (hoursToAccessExpiration >= (7 * 24)) { if (hoursToAccessExpiration >= (7 * 24)) {
if (offer) { // countdown to the first purchase discount if there is one if (offer) { // countdown to the first purchase discount if there is one
const hoursToDiscountExpiration = Math.floor((new Date(offer.expirationDate) - correctedTime) / 1000 / 60 / 60); const hoursToDiscountExpiration = Math.floor((new Date(offer.expirationDate) - correctedTime) / 1000 / 60 / 60);
upgradeNotificationHeaderText = ( upgradeCardHeaderText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.firstTimeLearnerDiscount" id="learning.outline.widgets.upgradeCard.firstTimeLearnerDiscount"
defaultMessage="{percentage}% First-Time Learner Discount" defaultMessage="{percentage}% First-Time Learner Discount"
values={{ values={{
percentage: (offer.percentage), percentage: (offer.percentage),
}} }}
/> />
); );
expirationBanner = ( expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToDiscountExpiration} />;
<ExpirationCountdown
hoursToExpiration={hoursToDiscountExpiration}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
type="offer"
/>
);
} else { } else {
upgradeNotificationHeaderText = ( upgradeCardHeaderText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.accessExpiration" id="learning.outline.widgets.upgradeCard.accessExpiration"
defaultMessage="Upgrade your course today" defaultMessage="Upgrade your course today"
/> />
); );
@@ -404,25 +363,18 @@ function UpgradeNotification({
<AccessExpirationDateBanner <AccessExpirationDateBanner
accessExpirationDate={accessExpirationDate} accessExpirationDate={accessExpirationDate}
timezoneFormatArgs={timezoneFormatArgs} timezoneFormatArgs={timezoneFormatArgs}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
/> />
); );
} }
upsellMessage = <UpsellFBEFarAwayCardContent />; upsellMessage = <UpsellFBEFarAwayCardContent />;
} else { // more urgent messaging if there's less than 7 days left to access expiration } else { // more urgent messaging if there's less than 7 days left to access expiration
upgradeNotificationHeaderText = ( upgradeCardHeaderText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.accessExpirationUrgent" id="learning.outline.widgets.upgradeCard.accessExpirationUrgent"
defaultMessage="Course Access Expiration" defaultMessage="Course Access Expiration"
/> />
); );
expirationBanner = ( expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToAccessExpiration} />;
<ExpirationCountdown
hoursToExpiration={hoursToAccessExpiration}
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
type="access"
/>
);
upsellMessage = ( upsellMessage = (
<UpsellFBESoonCardContent <UpsellFBESoonCardContent
accessExpirationDate={accessExpirationDate} accessExpirationDate={accessExpirationDate}
@@ -431,52 +383,36 @@ function UpgradeNotification({
); );
} }
} else { // FBE is turned off } else { // FBE is turned off
upgradeNotificationHeaderText = ( upgradeCardHeaderText = (
<FormattedMessage <FormattedMessage
id="learning.generic.upgradeNotification.pursueAverifiedCertificate" id="learning.outline.widgets.upgradeCard.pursueAverifiedCertificate"
defaultMessage="Pursue a verified certificate" defaultMessage="Pursue a verified certificate"
/> />
); );
upsellMessage = (<UpsellNoFBECardContent />); upsellMessage = (<UpsellNoFBECardContent />);
} }
if (offer) { // if there's a first purchase discount, message the code at the bottom
offerCode = (
<div className="text-center discount-info">
<FormattedMessage
id="learning.generic.upgradeNotification.code"
defaultMessage="Use code {code} at checkout"
values={{
code: (<span className="font-weight-bold">{offer.code}</span>),
}}
/>
</div>
);
}
return ( return (
<section className={classNames('upgrade-notification small', { 'card mb-4': shouldDisplayBorder })}> <section className="mb-4 card upgrade-card small">
<h2 className="h5 upgrade-notification-header" id="outline-sidebar-upgrade-header"> <h2 className="h5 upgrade-card-header" id="outline-sidebar-upgrade-header">
{upgradeNotificationHeaderText} {upgradeCardHeaderText}
</h2> </h2>
{expirationBanner} {expirationBanner}
<div className="upgrade-notification-message"> <div className="upgrade-card-message">
{upsellMessage} {upsellMessage}
</div> </div>
<div className="upgrade-notification-button"> <UpgradeButton
<UpgradeButton offer={offer}
offer={offer} onClick={logClick}
onClick={logClick} verifiedMode={verifiedMode}
verifiedMode={verifiedMode} className="upgrade-card-button"
block />
/>
</div>
{offerCode} {offerCode}
</section> </section>
); );
} }
UpgradeNotification.propTypes = { UpgradeCard.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
org: PropTypes.string.isRequired, org: PropTypes.string.isRequired,
accessExpiration: PropTypes.shape({ accessExpiration: PropTypes.shape({
@@ -488,10 +424,7 @@ UpgradeNotification.propTypes = {
percentage: PropTypes.number, percentage: PropTypes.number,
code: PropTypes.string, code: PropTypes.string,
}), }),
shouldDisplayBorder: PropTypes.bool,
setupgradeNotificationCurrentState: PropTypes.func,
timeOffsetMillis: PropTypes.number, timeOffsetMillis: PropTypes.number,
upsellPageName: PropTypes.string.isRequired,
userTimezone: PropTypes.string, userTimezone: PropTypes.string,
verifiedMode: PropTypes.shape({ verifiedMode: PropTypes.shape({
currencySymbol: PropTypes.string.isRequired, currencySymbol: PropTypes.string.isRequired,
@@ -500,15 +433,13 @@ UpgradeNotification.propTypes = {
}), }),
}; };
UpgradeNotification.defaultProps = { UpgradeCard.defaultProps = {
accessExpiration: null, accessExpiration: null,
contentTypeGatingEnabled: false, contentTypeGatingEnabled: false,
offer: null, offer: null,
setupgradeNotificationCurrentState: null,
shouldDisplayBorder: null,
timeOffsetMillis: 0, timeOffsetMillis: 0,
userTimezone: null, userTimezone: null,
verifiedMode: null, verifiedMode: null,
}; };
export default injectIntl(UpgradeNotification); export default injectIntl(UpgradeCard);

View File

@@ -0,0 +1,59 @@
.upgrade-card {
border-radius: 0 !important;
}
.upgrade-card-header{
margin: 1.25rem;
}
.upsell-warning{
background-color: $danger-100;
}
.upsell-warning-light{
background-color: $warning-100;
}
.upsell-warning, .upsell-warning-light{
padding-left: 1.25rem;
padding-right: 1.25rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.upgrade-card-ul{
margin-left: 3rem;
padding-top: 0.875rem;
padding-right: 1.25rem;
}
.upgrade-card-li{
left: -2.125rem;
top: 0 !important;
}
.upgrade-card-text{
padding-top: 0.875rem;
padding-right: 1.25rem;
padding-left: 1.25rem;
}
.upgrade-card-button{
margin-left: 1.25rem;
margin-right: 1.25rem;
margin-bottom: 1.25rem;
}
.discount-info {
border-top: 1px solid rgba(0, 0, 0, 0.125);
padding-top: .75rem;
padding-bottom: .75rem;
}
.inline-link-underline {
text-decoration: underline;
}
.upgrade-card .upgrade-card-message a{
color: $primary-500;
}

View File

@@ -1,15 +1,8 @@
import React from 'react'; import React from 'react';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { import { initializeMockApp, render, screen } from '../../../setupTest';
fireEvent, import UpgradeCard from './UpgradeCard';
initializeMockApp,
render,
screen,
waitFor,
} from '../../setupTest';
import UpgradeNotification from './UpgradeNotification';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
@@ -18,30 +11,12 @@ jest
.spyOn(global.Date, 'now') .spyOn(global.Date, 'now')
.mockImplementation(() => dateNow.valueOf()); .mockImplementation(() => dateNow.valueOf());
describe('Upgrade Notification', () => { describe('Upgrade Card', () => {
function buildAndRender(attributes) { function buildAndRender(attributes) {
const upgradeNotificationData = Factory.build('upgradeNotificationData', { ...attributes }); const upgradeCardData = Factory.build('upgradeCardData', { ...attributes });
render(<UpgradeNotification {...upgradeNotificationData} />); render(<UpgradeCard {...upgradeCardData} />);
} }
it('sends upgrade click info to segment', async () => {
sendTrackEvent.mockClear();
buildAndRender({ pageName: 'test' });
const upgradeButton = await waitFor(() => screen.queryByRole('link', { name: 'Upgrade for $149' }));
fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.bi.ecommerce.upsell_links_clicked', {
org_key: 'edX',
courserun_key: 'course-v1:edX+DemoX+Demo_Course',
linkCategory: 'green_upgrade',
linkName: 'test_green',
linkType: 'button',
pageName: 'test',
});
});
it('does not render when there is no verified mode', async () => { it('does not render when there is no verified mode', async () => {
buildAndRender({ verifiedMode: null }); buildAndRender({ verifiedMode: null });
expect(screen.queryByRole('link', { name: 'Upgrade for $149' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Upgrade for $149' })).not.toBeInTheDocument();
@@ -50,7 +25,7 @@ describe('Upgrade Notification', () => {
it('renders non-FBE when there is a verified mode but no FBE', async () => { it('renders non-FBE when there is a verified mode but no FBE', async () => {
buildAndRender(); buildAndRender();
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
}); });
@@ -64,7 +39,7 @@ describe('Upgrade Notification', () => {
}, },
}); });
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
}); });
@@ -74,31 +49,11 @@ describe('Upgrade Notification', () => {
contentTypeGatingEnabled: true, contentTypeGatingEnabled: true,
}); });
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
}); });
it('renders non-FBE with a discount properly', async () => {
const discountExpirationDate = new Date(dateNow);
discountExpirationDate.setDate(discountExpirationDate.getDate() + 6);
buildAndRender({
offer: {
expirationDate: discountExpirationDate.toString(),
percentage: 15,
code: 'Welcome15',
discountedPrice: '$126.65',
originalPrice: '$149',
upgradeUrl: 'www.exampleUpgradeUrl.com',
},
});
expect(screen.getByRole('heading', { name: 'Pursue a verified certificate' })).toBeInTheDocument();
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
expect(screen.getByText(/Upgrade for/).textContent).toMatch('$126.65 ($149)');
expect(screen.getByText(/Use code.*?at checkout/s).textContent).toMatch('Use code Welcome15 at checkout');
});
it('renders FBE expiration within an hour properly', async () => { it('renders FBE expiration within an hour properly', async () => {
const expirationDate = new Date(dateNow); const expirationDate = new Date(dateNow);
expirationDate.setMinutes(expirationDate.getMinutes() + 45); expirationDate.setMinutes(expirationDate.getMinutes() + 45);
@@ -158,7 +113,7 @@ describe('Upgrade Notification', () => {
}); });
expect(screen.getByRole('heading', { name: 'Upgrade your course today' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Upgrade your course today' })).toBeInTheDocument();
expect(screen.getByText(/Course access will expire/s).textContent).toMatch('Course access will expire April 27'); expect(screen.getByText(/Course access will expire/s).textContent).toMatch('Course access will expire April 27');
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments'); expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends'); expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
@@ -186,7 +141,7 @@ describe('Upgrade Notification', () => {
}); });
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
expect(screen.getByText('Less than 1 hour left')).toBeInTheDocument(); expect(screen.getByText('Less than 1 hour left')).toBeInTheDocument();
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments'); expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends'); expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
@@ -215,7 +170,7 @@ describe('Upgrade Notification', () => {
}); });
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
expect(screen.getByText(/hours left/s).textContent).toMatch('12 hours left'); expect(screen.getByText(/hours left/s).textContent).toMatch('12 hours left');
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments'); expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends'); expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');
@@ -244,7 +199,7 @@ describe('Upgrade Notification', () => {
}); });
expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '15% First-Time Learner Discount' })).toBeInTheDocument();
expect(screen.getByText(/days left/s).textContent).toMatch('6 days left'); expect(screen.getByText(/days left/s).textContent).toMatch('6 days left');
expect(screen.getByText(/Earn a.*?of completion to showcase on your resumé/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resumé'); expect(screen.getByText(/Earn a.*?of completion to showcase on your resume/s).textContent).toMatch('Earn a verified certificate of completion to showcase on your resume');
expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments'); expect(screen.getByText(/Unlock your access/s).textContent).toMatch('Unlock your access to all course activities, including graded assignments');
expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends'); expect(screen.getByText(/to course content and materials/s).textContent).toMatch('Full access to course content and materials, even after the course ends');
expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX'); expect(screen.getByText(/Support our.*?at edX/s).textContent).toMatch('Support our non-profit mission at edX');

View File

@@ -2,13 +2,14 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Button, TransitionReplace } from '@edx/paragon'; import { Button, TransitionReplace } from '@edx/paragon';
import truncate from 'truncate-html'; import truncate from 'truncate-html';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment'; import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages'; import messages from '../messages';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
import { Alert } from '../../../generic/user-messages';
import { dismissWelcomeMessage } from '../../data/thunks'; import { dismissWelcomeMessage } from '../../data/thunks';
function WelcomeMessage({ courseId, intl }) { function WelcomeMessage({ courseId, intl }) {
@@ -26,47 +27,52 @@ function WelcomeMessage({ courseId, intl }) {
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length; const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened); const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
const dispatch = useDispatch(); const dispatch = useDispatch();
return ( return (
<Alert display && (
data-testid="alert-container-welcome" <Alert
variant="light" type="welcome"
stacked dismissible
dismissible onDismiss={() => {
show={display} setDisplay(false);
onClose={() => { dispatch(dismissWelcomeMessage(courseId));
setDisplay(false); }}
dispatch(dismissWelcomeMessage(courseId)); footer={messageCanBeShortened && (
}} <div className="row w-100 m-0">
actions={messageCanBeShortened ? [ <div className="col-12 col-sm-auto p-0">
<Button <Button
onClick={() => setShowShortMessage(!showShortMessage)} block
variant="outline-primary" onClick={() => setShowShortMessage(!showShortMessage)}
> variant="outline-primary"
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton) >
: intl.formatMessage(messages.welcomeMessageShowLessButton)} {showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
</Button>, : intl.formatMessage(messages.welcomeMessageShowLessButton)}
] : []} </Button>
> </div>
<TransitionReplace className="mb-3" enterDuration={400} exitDuration={200}> </div>
{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> <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 }) { function ProgressHeader({ intl }) {
const { const {
courseId, courseId,
targetUserId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { administrator, userId } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();
const { studioUrl, username } = useModel('progress', courseId); const { studioUrl } = useModel('progress', courseId);
const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId);
const pageTitle = viewingOtherStudentsProgressPage
? intl.formatMessage(messages.progressHeaderForTargetUser, { username })
: intl.formatMessage(messages.progressHeader);
return ( return (
<> <>
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between"> <div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
<h1>{pageTitle}</h1> <h1>{intl.formatMessage(messages.progressHeader)}</h1>
{administrator && studioUrl && ( {administrator && studioUrl && (
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}> <Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
{intl.formatMessage(messages.studioLink)} {intl.formatMessage(messages.studioLink)}

View File

@@ -18,10 +18,12 @@ function ProgressTab() {
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
gradesFeatureIsFullyLocked, completionSummary: {
lockedCount,
},
} = useModel('progress', courseId); } = useModel('progress', courseId);
const isLocked = lockedCount > 0;
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const applyLockedOverlay = isLocked ? 'locked-overlay' : '';
const layout = layoutGenerator({ const layout = layoutGenerator({
mobile: 0, mobile: 0,
@@ -41,7 +43,7 @@ function ProgressTab() {
<CertificateStatus /> <CertificateStatus />
</OnMobile> </OnMobile>
<CourseGrade /> <CourseGrade />
<div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}> <div className={`grades my-4 p-4 rounded shadow-sm ${applyLockedOverlay}`} aria-hidden={isLocked}>
<GradeSummary /> <GradeSummary />
<DetailedGrades /> <DetailedGrades />
</div> </div>

View File

@@ -12,7 +12,6 @@ import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import * as thunks from '../data/thunks'; import * as thunks from '../data/thunks';
import initializeStore from '../../store'; import initializeStore from '../../store';
import ProgressTab from './ProgressTab'; import ProgressTab from './ProgressTab';
import LoadedTabPage from '../../tab-page/LoadedTabPage';
initializeMockApp(); initializeMockApp();
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
@@ -21,10 +20,9 @@ describe('Progress Tab', () => {
let axiosMock; let axiosMock;
const courseId = 'course-v1:edX+Test+run'; const courseId = 'course-v1:edX+Test+run';
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`); const progressUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`;
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const store = initializeStore(); const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId }); const defaultMetadata = Factory.build('courseHomeMetadata', { id: courseId });
@@ -51,7 +49,6 @@ describe('Progress Tab', () => {
// Set defaults for network requests // Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(progressUrl).reply(200, defaultTabData); axiosMock.onGet(progressUrl).reply(200, defaultTabData);
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
logUnhandledRequests(axiosMock); logUnhandledRequests(axiosMock);
}); });
@@ -114,7 +111,6 @@ describe('Progress Tab', () => {
assignment_type: 'Homework', assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection', display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 2, num_points_possible: 2,
@@ -180,7 +176,6 @@ describe('Progress Tab', () => {
assignment_type: 'Homework', assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection', display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 8, num_points_earned: 8,
num_points_possible: 10, num_points_possible: 10,
@@ -257,26 +252,6 @@ describe('Progress Tab', () => {
sku: 'ABCD1234', sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade', 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(); await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument(); expect(screen.getByText('locked feature')).toBeInTheDocument();
@@ -284,7 +259,7 @@ describe('Progress Tab', () => {
expect(screen.getAllByRole('link', 'Unlock now')).toHaveLength(3); expect(screen.getAllByRole('link', 'Unlock now')).toHaveLength(3);
}); });
it('sends events on click of upgrade button in locked content header (CourseGradeHeader)', async () => { it('sends event on click of upgrade button in locked content header (CourseGradeHeader)', async () => {
sendTrackEvent.mockClear(); sendTrackEvent.mockClear();
setTabData({ setTabData({
completion_summary: { completion_summary: {
@@ -300,26 +275,6 @@ describe('Progress Tab', () => {
sku: 'ABCD1234', sku: 'ABCD1234',
upgrade_url: 'edx.org/upgrade', 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(); await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument(); expect(screen.getByText('locked feature')).toBeInTheDocument();
@@ -328,20 +283,12 @@ describe('Progress Tab', () => {
const upgradeButton = screen.getAllByRole('link', 'Unlock now')[0]; const upgradeButton = screen.getAllByRole('link', 'Unlock now')[0];
fireEvent.click(upgradeButton); fireEvent.click(upgradeButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(2); expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.grades_upgrade.clicked', { expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.grades_upgrade.clicked', {
org_key: 'edX', org_key: 'edX',
courserun_key: courseId, courserun_key: courseId,
is_staff: false, 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 () => { it('renders locked feature preview with no upgrade button when user has locked content but cannot upgrade', async () => {
@@ -351,26 +298,6 @@ describe('Progress Tab', () => {
incomplete_count: 1, incomplete_count: 1,
locked_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(); await fetchAndRender();
expect(screen.getByText('locked feature')).toBeInTheDocument(); expect(screen.getByText('locked feature')).toBeInTheDocument();
@@ -382,62 +309,6 @@ describe('Progress Tab', () => {
expect(screen.queryByText('locked feature')).not.toBeInTheDocument(); 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 () => { 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 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%. // The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
@@ -450,7 +321,6 @@ describe('Progress Tab', () => {
assignment_type: 'Homework', assignment_type: 'Homework',
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345', block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
display_name: 'First subsection', display_name: 'First subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 2, num_points_possible: 2,
@@ -467,7 +337,6 @@ describe('Progress Tab', () => {
{ {
assignment_type: 'Homework', assignment_type: 'Homework',
display_name: 'Second subsection', display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 1, num_points_possible: 1,
@@ -662,7 +531,6 @@ describe('Progress Tab', () => {
{ {
assignment_type: 'Homework', assignment_type: 'Homework',
display_name: 'Second subsection', display_name: 'Second subsection',
learner_has_access: true,
has_graded_assignment: true, has_graded_assignment: true,
num_points_earned: 1, num_points_earned: 1,
num_points_possible: 1, num_points_possible: 1,
@@ -686,8 +554,8 @@ describe('Progress Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.getByText('Detailed grades')).toBeInTheDocument(); expect(screen.getByText('Detailed grades')).toBeInTheDocument();
expect(screen.getByText('First subsection')); expect(screen.getByRole('link', { name: 'First subsection' }));
expect(screen.getByText('Second subsection')); expect(screen.getByRole('link', { name: 'Second subsection' }));
}); });
it('sends event on click of subsection link', async () => { it('sends event on click of subsection link', async () => {
@@ -723,20 +591,6 @@ describe('Progress Tab', () => {
}); });
}); });
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);
});
it('render message when section scores are not populated', async () => { it('render message when section scores are not populated', async () => {
setTabData({ setTabData({
section_scores: [], section_scores: [],
@@ -1104,21 +958,13 @@ describe('Progress Tab', () => {
const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' }); const upgradeLink = screen.getByRole('link', { name: 'Upgrade now' });
fireEvent.click(upgradeLink); fireEvent.click(upgradeLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(3); expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', { expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX', org_key: 'edX',
courserun_key: courseId, courserun_key: courseId,
is_staff: false, is_staff: false,
certificate_status_variant: 'audit_passing', 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 () => { it('Displays nothing if audit only', async () => {
@@ -1188,74 +1034,4 @@ describe('Progress Tab', () => {
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument(); 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

@@ -36,9 +36,6 @@ function CertificateStatus({ intl }) {
verificationData, verificationData,
verifiedMode, verifiedMode,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const {
certificateAvailableDate,
} = certificateData || {};
const mode = getCourseExitMode( const mode = getCourseExitMode(
certificateData, certificateData,
@@ -46,12 +43,6 @@ function CertificateStatus({ intl }) {
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
); );
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const dispatch = useDispatch(); const dispatch = useDispatch();
const { administrator } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();
@@ -72,7 +63,6 @@ function CertificateStatus({ intl }) {
let buttonLocation; let buttonLocation;
let buttonText; let buttonText;
let endDate; let endDate;
let certAvailabilityDate;
let gradeEventName = 'not_passing'; let gradeEventName = 'not_passing';
if (userHasPassingGrade) { if (userHasPassingGrade) {
@@ -83,21 +73,17 @@ function CertificateStatus({ intl }) {
const idVerificationSupportLink = <IdVerificationSupportLink />; const idVerificationSupportLink = <IdVerificationSupportLink />;
const profileLink = <ProfileLink />; 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) { if (mode === COURSE_EXIT_MODES.disabled) {
certEventName = 'certificate_status_disabled'; certEventName = 'certificate_status_disabled';
} else if (mode === COURSE_EXIT_MODES.nonPassing && !certIsDownloadable) { } else if (mode === COURSE_EXIT_MODES.nonPassing) {
certCase = 'notPassing'; certCase = 'notPassing';
certEventName = 'not_passing'; certEventName = 'not_passing';
body = intl.formatMessage(messages[`${certCase}Body`]); body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.inProgress && !certIsDownloadable) { } else if (mode === COURSE_EXIT_MODES.inProgress) {
certCase = 'inProgress'; certCase = 'inProgress';
certEventName = 'has_scheduled_content'; certEventName = 'has_scheduled_content';
body = intl.formatMessage(messages[`${certCase}Body`]); body = intl.formatMessage(messages[`${certCase}Body`]);
} else if (mode === COURSE_EXIT_MODES.celebration || certIsDownloadable) { } else if (mode === COURSE_EXIT_MODES.celebration) {
switch (certStatus) { switch (certStatus) {
case 'requesting': case 'requesting':
certCase = 'requestable'; certCase = 'requestable';
@@ -130,7 +116,7 @@ function CertificateStatus({ intl }) {
<FormattedMessage <FormattedMessage
id="progress.certificateStatus.downloadableBody" id="progress.certificateStatus.downloadableBody"
defaultMessage=" defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today. Showcase your accomplishment on LinkedIn or your resume today.
You can download your certificate now and access it any time from your You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}." {dashboardLink} and {profileLink}."
values={{ dashboardLink, profileLink }} values={{ dashboardLink, profileLink }}
@@ -151,13 +137,12 @@ function CertificateStatus({ intl }) {
case 'earned_but_not_available': case 'earned_but_not_available':
certCase = 'notAvailable'; certCase = 'notAvailable';
endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />; endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
certAvailabilityDate = <FormattedDate value={certificateAvailableDate} day="numeric" month="long" year="numeric" />;
body = ( body = (
<FormattedMessage <FormattedMessage
id="courseCelebration.certificateBody.notAvailable.endDate" id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="This course ends on {endDate}. Final grades and certificates are defaultMessage="Your certificate will be available soon! After this course officially ends on {endDate}, you will receive an
scheduled to be available after {certAvailabilityDate}." email notification with your certificate."
values={{ endDate, certAvailabilityDate }} values={{ endDate }}
/> />
); );
break; break;
@@ -208,15 +193,6 @@ function CertificateStatus({ intl }) {
is_staff: administrator, is_staff: administrator,
certificate_status_variant: certEventName, certificate_status_variant: certEventName,
}); });
if (certCase === 'upgrade') {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
...eventProperties,
linkCategory: '(none)',
linkName: 'progress_certificate',
linkType: 'button',
pageName: 'progress',
});
}
}; };
return ( return (

View File

@@ -47,7 +47,7 @@ const messages = defineMessages({
}, },
downloadableBody: { downloadableBody: {
id: 'progress.certificateStatus.downloadableBody', id: 'progress.certificateStatus.downloadableBody',
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.', defaultMessage: 'Showcase your accomplishment on LinkedIn or your resume today. You can download your certificate now and access it any time from your Dashboard and Profile.',
}, },
downloadableButton: { downloadableButton: {
id: 'progress.certificateStatus.downloadableButton', id: 'progress.certificateStatus.downloadableButton',
@@ -61,6 +61,10 @@ const messages = defineMessages({
id: 'progress.certificateStatus.notAvailableHeader', id: 'progress.certificateStatus.notAvailableHeader',
defaultMessage: 'Certificate status', defaultMessage: 'Certificate status',
}, },
notAvailableBody: {
id: 'progress.certificateStatus.notAvailableBody',
defaultMessage: 'Your certificate will be available soon! After this course officially ends on {end_date}, you will receive an email notification with your certificate.',
},
upgradeHeader: { upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader', id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate', defaultMessage: 'Earn a certificate',
@@ -73,18 +77,6 @@ const messages = defineMessages({
id: 'progress.certificateStatus.upgradeButton', id: 'progress.certificateStatus.upgradeButton',
defaultMessage: 'Upgrade now', 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; export default messages;

View File

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

View File

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

View File

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

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