Compare commits

..

5 Commits

Author SHA1 Message Date
Ihor Romaniuk
72bce9e652 fix: save scroll position on exit from video xblock fullscreen mode (#981)
* fix: save scroll position on exit from video xblock fullscreen mode

* fix: change initial scroll top value

* fix: update tests
2023-06-21 13:45:34 -04:00
Eugene Dyudyunov
66482ab23a fix: first section celebration
Fix the first section celebration modal showing logic.

On Nutmeg+ it's shown only after the page reload or after going directly
to the second section from the course home. Going through the course
with the Next/Previous buttons has no effect (which worked on Maple).

Notes:
- the weekly goal has the same showing logic, but I assume that is
correct behavior so no changes are added for it in this commit.
- showing a celebration modal for the first section completion when
going directly to the first unit of the second section seems to be a bug
(reproduces on Maple too)
2023-02-14 15:40:50 -05:00
Ihor Romaniuk
03e0f41692 fix: fix alignment in the streak celebration modal (#974)
* fix: fix alignment in the streak celebration modal

* chore: update package-log
2022-12-05 11:54:48 -05:00
Diana Catalina Olarte
8e1904a235 fix: show site name instead of edX
(cherry picked from commit cafb881a61)
2022-05-18 15:52:10 +01:00
Michael Terry
88485a0f77 fix: add back es-check & fsevents for now to fix build
A previous commit (7f37575) dropped es-check, which dropped
fsevents, which caused our build system (which is still using
npm@6) to fail with an error like `Unsupported platform for
fsevents` when trying to install fsevents through a dependency
(e.g. when installing npm aliases).

I am reintroducing all the package-lock changes from that commit
to get back fsevents in a state where that error does not occur.

I think a longer-term fix would be to instead upgrade our build
system to node16 / npm6. But this is an easy fix for now to unblock
the builds.
2022-04-14 10:16:14 -04:00
330 changed files with 42731 additions and 26024 deletions

4
.env
View File

@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL=''
@@ -29,8 +28,6 @@ LOGO_WHITE_URL=''
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL=''
SEGMENT_KEY=''
@@ -46,4 +43,3 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -29,8 +28,6 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
@@ -46,6 +43,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''

View File

@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -29,8 +28,6 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''
@@ -45,4 +42,3 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'

View File

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

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: openedx/.github/.github/workflows/commitlint.yml@master
uses: edx/.github/.github/workflows/commitlint.yml@master

View File

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

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

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

View File

@@ -9,13 +9,14 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VER }}
node-version: ${{ matrix.node }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v3

2
.gitignore vendored
View File

@@ -1,8 +1,6 @@
.DS_Store
.eslintcache
.idea
*.swp
*.swo
node_modules
npm-debug.log
coverage

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

@@ -1,7 +1,6 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fa_IR,fr_CA,it_IT,pt_PT,de_DE"
transifex_langs = "ar,fr,es_419,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
@@ -43,24 +42,9 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,12 +1,10 @@
#####################
frontend-app-learning
######################
|codecov| |license|
********
Purpose
********
frontend-app-learning
=========================
Introduction
------------
This is the Learning MFE (micro-frontend application), which renders all
learner-facing course pages (like the course outline, the progress page,
@@ -17,58 +15,21 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
***************
Getting Started
***************
Development
-----------
Prerequisites
=============
Start Devstack
^^^^^^^^^^^^^^
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms``
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
Cloning and Startup
===================
.. code-block::
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-learning.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-learning && npm ci``
4. Start the dev server:
``npm start``
Local module development
=========================
^^^^^^^^^^^^^^^^^^^^^^^^
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance::
@@ -91,17 +52,17 @@ file (which is git-ignored) that defines where to find your local modules, for i
],
};
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment
==========
----------
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
edX Developer Guide's section on
`MFE applications <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html>`_.
Environment Variables
======================
^^^^^^^^^^^^^^^^^^^^^
This MFE is configured via environment variables supplied at build time.
All micro-frontends have a shared set of required environment variables,
@@ -158,59 +119,3 @@ TWITTER_URL
Example: https://twitter.com/edXOnline
Getting Help
===========
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-learning/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.

View File

@@ -9,12 +9,4 @@ module.exports = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000,
testEnvironment: 'jsdom'
});

50618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Frontend learning application.",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-learning.git"
"url": "git+https://github.com/edx/frontend-app-learning.git"
},
"browserslist": [
"extends @edx/browserslist-config"
@@ -21,63 +21,58 @@
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-learning/issues"
"url": "https://github.com/edx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.2.1",
"@edx/frontend-component-header": "4.6.0",
"@edx/frontend-lib-learning-assistant": "^1.16.0",
"@edx/frontend-lib-special-exams": "2.23.3",
"@edx/frontend-platform": "5.5.2",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.2.2",
"@edx/frontend-component-header": "2.4.6",
"@edx/frontend-lib-special-exams": "1.16.3",
"@edx/frontend-platform": "1.15.6",
"@edx/paragon": "19.14.1",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.1.4",
"@popperjs/core": "2.11.8",
"@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "5.3.0",
"js-cookie": "3.0.5",
"classnames": "2.3.1",
"core-js": "3.21.1",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "^7.1.3",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.15.0",
"react-router-dom": "6.15.0",
"react-share": "4.4.1",
"react-redux": "7.2.8",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.8",
"regenerator-runtime": "0.13.9",
"reselect": "4.1.5",
"truncate-html": "1.0.4",
"util": "0.12.5"
"util": "0.12.4"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "^12.9.10",
"@edx/reactifex": "2.2.0",
"@pact-foundation/pact": "^11.0.2",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "9.1.4",
"@edx/reactifex": "1.1.0",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"codecov": "3.8.3",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "29.5.0",
"jest": "27.5.1",
"rosie": "2.1.0"
}
}

View File

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

View File

@@ -1,3 +1,4 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
@@ -7,8 +8,18 @@ import { Alert, Hyperlink } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import messages from './messages';
import AccessExpirationAlertMMP2P from './AccessExpirationAlertMMP2P';
function AccessExpirationAlert({ intl, payload }) {
/** [MM-P2P] Experiment */
const [showMMP2P, setShowMMP2P] = useState(!!window.experiment__home_alert_bShowMMP2P);
if (window.experiment__home_alert_showMMP2P === undefined) {
window.experiment__home_alert_showMMP2P = (val) => {
window.experiment__home_alert_bShowMMP2P = !!val;
setShowMMP2P(!!val);
};
}
const AccessExpirationAlert = ({ intl, payload }) => {
const {
accessExpiration,
courseId,
@@ -28,6 +39,13 @@ const AccessExpirationAlert = ({ intl, payload }) => {
upgradeUrl,
} = accessExpiration;
/** [MM-P2P] Experiment */
if (showMMP2P) {
return (
<AccessExpirationAlertMMP2P payload={payload} />
);
}
const logClick = () => {
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
org_key: org,
@@ -116,7 +134,7 @@ const AccessExpirationAlert = ({ intl, payload }) => {
{deadlineMessage}
</Alert>
);
};
}
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
const AccessExpirationMasqueradeBanner = ({ payload }) => {
function AccessExpirationMasqueradeBanner({ payload }) {
const {
expirationDate,
userTimezone,
@@ -27,7 +27,7 @@ const AccessExpirationMasqueradeBanner = ({ payload }) => {
/>
</PageBanner>
);
};
}
AccessExpirationMasqueradeBanner.propTypes = {
payload: PropTypes.shape({

View File

@@ -7,17 +7,17 @@ const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpira
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
const payload = useMemo(() => ({
const payload = {
accessExpiration,
courseId,
org,
userTimezone,
analyticsPageName,
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]);
};
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic,
});
@@ -34,14 +34,14 @@ export function useAccessExpirationMasqueradeBanner(courseId, tab) {
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
const expirationDate = accessExpiration && accessExpiration.expirationDate;
const payload = useMemo(() => ({
const payload = {
expirationDate,
userTimezone,
}), [expirationDate, userTimezone]);
};
useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelativeTime,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
@@ -11,11 +11,9 @@ import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
const CourseStartAlert = ({ payload }) => {
function CourseStartAlert({ payload }) {
const {
courseId,
} = payload;
@@ -27,17 +25,15 @@ const CourseStartAlert = ({ payload }) => {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const delta = new Date(startDate) - new Date();
const timeRemaining = (
<FormattedRelativeTime
<FormattedRelative
key="timeRemaining"
value={delta / 1000}
numeric="auto"
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
updateIntervalInSeconds={YEAR_SEC}
value={startDate}
{...timezoneFormatArgs}
/>
);
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert variant="info" icon={Info}>
@@ -68,7 +64,7 @@ const CourseStartAlert = ({ payload }) => {
<Alert variant="info" icon={Info}>
<strong>
<FormattedMessage
id="learning.outline.alert.start.long"
id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away."
values={{
@@ -88,13 +84,13 @@ const CourseStartAlert = ({ payload }) => {
</strong>
<br />
<FormattedMessage
id="learning.outline.alert.start.calendar"
id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/>
</Alert>
);
};
}
CourseStartAlert.propTypes = {
payload: PropTypes.shape({

View File

@@ -5,7 +5,7 @@ import { PageBanner } from '@edx/paragon';
import { useModel } from '../../generic/model-store';
const CourseStartMasqueradeBanner = ({ payload }) => {
function CourseStartMasqueradeBanner({ payload }) {
const {
courseId,
} = payload;
@@ -33,7 +33,7 @@ const CourseStartMasqueradeBanner = ({ payload }) => {
/>
</PageBanner>
);
};
}
CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({

View File

@@ -5,7 +5,7 @@ import { useModel } from '../../generic/model-store';
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
function IsStartDateInFuture(courseId) {
function isStartDateInFuture(courseId) {
const {
start,
} = useModel('courseHomeMeta', courseId);
@@ -20,15 +20,15 @@ function useCourseStartAlert(courseId) {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
const isVisible = isEnrolled && isStartDateInFuture(courseId);
const payload = useMemo(() => ({
const payload = {
courseId,
}), [courseId]);
};
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});
@@ -42,15 +42,15 @@ export function useCourseStartMasqueradeBanner(courseId, tab) {
isMasquerading,
} = useModel('courseHomeMeta', courseId);
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId);
const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
const payload = useMemo(() => ({
const payload = {
courseId,
}), [courseId]);
};
useAlert(isVisible, {
code: 'clientCourseStartMasqueradeBanner',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts',
});

View File

@@ -11,7 +11,7 @@ import { useModel } from '../../generic/model-store';
import messages from './messages';
import useEnrollClickHandler from './clickHook';
const EnrollmentAlert = ({ intl, payload }) => {
function EnrollmentAlert({ intl, payload }) {
const {
canEnroll,
courseId,
@@ -55,7 +55,7 @@ const EnrollmentAlert = ({ intl, payload }) => {
</div>
</Alert>
);
};
}
EnrollmentAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -27,7 +27,7 @@ function useEnrollClickHandler(courseId, orgId, successText) {
});
global.location.reload();
});
}, [addFlash, courseId, orgId, successText]);
}, [courseId]);
return { enrollClickHandler, loading };
}

View File

@@ -22,16 +22,16 @@ export function useEnrollmentAlert(courseId) {
* 3. the course is private.
*/
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
const payload = useMemo(() => ({
const payload = {
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff,
}), [course, courseId, outline]);
};
useAlert(isVisible, {
code: 'clientEnrollmentAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline',
});

View File

@@ -13,9 +13,9 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
import { sendActivationEmail } from '../../courseware/data';
import messages from './messages';
const AccountActivationAlert = ({
function AccountActivationAlert({
intl,
}) => {
}) {
const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false);
@@ -123,7 +123,7 @@ const AccountActivationAlert = ({
{children()}
</AlertModal>
);
};
}
AccountActivationAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -7,7 +7,7 @@ import { WarningFilled } from '@edx/paragon/icons';
import genericMessages from '../../generic/messages';
const LogistrationAlert = ({ intl }) => {
function LogistrationAlert({ intl }) {
const signIn = (
<Hyperlink
style={{ textDecoration: 'underline' }}
@@ -41,7 +41,7 @@ const LogistrationAlert = ({ intl }) => {
/>
</Alert>
);
};
}
LogistrationAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -35,8 +35,7 @@ function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
if (entranceExamPassed) {
entranceExamText = intl.formatMessage(
messages.entranceExamTextPassed,
{ entranceExamCurrentScore: entranceExamCurrentScore * 100 },
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
);
} else {
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {

View File

@@ -1,33 +0,0 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -1,95 +0,0 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import {
Close,
} from '@edx/paragon/icons';
import { setShowSearch } from '../data/slice';
import { useElementBoundingBox, useLockScroll } from './hooks';
import messages from './messages';
const CoursewareSearch = ({ intl, ...sectionProps }) => {
const dispatch = useDispatch();
useLockScroll();
const info = useElementBoundingBox('courseTabsNavigation');
const top = info ? `${Math.floor(info.top)}px` : 0;
return (
<section className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-section" {...sectionProps}>
<div className="courseware-search__close">
<Button
variant="tertiary"
className="p-1"
aria-label={intl.formatMessage(messages.searchCloseAction)}
onClick={() => dispatch(setShowSearch(false))}
data-testid="courseware-search-close-button"
><Icon src={Close} />
</Button>
</div>
<div className="courseware-search__outer-content">
<div className="courseware-search__content" style={{ height: '999px' }}>
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis semper rutrum odio quis congue.
Duis sodales nibh et sapien elementum fermentum. Quisque magna urna, gravida at gravida et,
ultricies vel massa.Aliquam in vehicula dolor, id scelerisque felis.
Morbi posuere scelerisque tincidunt. Proin et gravida tortor. Vestibulum vel orci vulputate,
gravida justo eu, varius dolor. Etiam viverra diam sed est tincidunt, et aliquam est efficitur.
Donec imperdiet eros quis est condimentum faucibus.
</p>
<p>
In mattis, tellus ut lacinia viverra, ligula ex sagittis ex, sed mollis ex enim ut velit.
Nunc elementum, risus eget feugiat scelerisque, sapien felis laoreet nisl, ut pharetra neque
lorem a elit. Maecenas elementum, metus fringilla suscipit imperdiet, mi nunc efficitur elit,
sed consequat massa magna sit amet dui. Curabitur ultrices nisi vel lorem scelerisque, pharetra
luctus nunc pulvinar. Morbi aliquam ante eget arcu condimentum consectetur. Fusce faucibus lacus
sed pretium ultrices. Curabitur neque lacus, elementum convallis augue placerat, gravida
scelerisque ipsum. Donec bibendum lectus id ullamcorper sodales. Integer quis ante facilisis erat
maximus viverra. Nunc rutrum posuere lectus, aliquam congue odio blandit nec. Phasellus placerat,
magna non bibendum lacinia, tortor orci vulputate dui, vitae imperdiet turpis dui nec tortor.
Praesent porttitor mollis diam ut gravida. Praesent vitae felis dignissim sem accumsan dignissim.
Fusce ullamcorper bibendum ante ac pellentesque. Aliquam sed leo vel leo pellentesque cursus a at risus.
Donec sollicitudin maximus diam, sit amet molestie sapien commodo at.
</p>
<p>
Cras ornare pulvinar est id rhoncus. Aenean ut risus magna. Fusce cursus pulvinar dui ut egestas.
Quisque condimentum risus non mi sagittis, eu facilisis enim hendrerit. Integer faucibus dapibus rutrum.
Nullam vitae mollis tortor, eu lacinia mi. Nunc commodo ex id eros hendrerit, vel interdum augue tristique.
Suspendisse ullamcorper, purus in vestibulum auctor, justo nisi finibus dolor,
nec dignissim arcu enim a augue.
</p>
<p>
Fusce vel libero odio. Orci varius natoque penatibus et magnis dis parturient montes,
nascetur ridiculus mus. Pellentesque at varius turpis. Ut pulvinar efficitur congue. Vivamus cursus,
purus at aliquet malesuada, felis quam blandit dolor, a interdum justo est semper augue.
In eu lectus sit amet est pellentesque porta vel eget magna. Morbi sollicitudin turpis vitae faucibus
pulvinar. Etiam placerat pulvinar porta.
</p>
<p>
Suspendisse mattis eget felis non sagittis. Nulla facilisi. In bibendum cursus purus, non venenatis tellus
dignissim sit amet. Phasellus volutpat ipsum turpis, non imperdiet nisi elementum a. Nunc mollis, sapien
cursus vehicula consectetur, nunc turpis pulvinar mauris, at varius justo mi egestas nisi. Fusce semper
sapien in orci rhoncus ornare. Donec maximus mi eu pulvinar convallis.
</p>
<p>
Nullam tortor sem, hendrerit eu sapien ac, venenatis rhoncus ligula. Donec nibh leo, venenatis sed interdum
ac, pharetra sed nibh. Orci varius natoque penatibus et magnis dis parturient montes,
nascetur ridiculus mus. Sed congue risus eu mattis condimentum. In id nulla sit amet magna suscipit
consectetur. Nullam vitae augue felis. In consequat tempus diam, a eleifend ante bibendum ac.
Vivamus mi orci, fermentum ac viverra quis, tristique a ipsum. Morbi imperdiet porta sem, in sollicitudin
risus dignissim at. Nulla dapibus iaculis vestibulum.
</p>
</div>
</div>
</section>
);
};
CoursewareSearch.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearch);

View File

@@ -1,84 +0,0 @@
import React from 'react';
import {
fireEvent,
initializeMockApp,
render,
screen,
} from '../../setupTest';
import { CoursewareSearch } from './index';
import { setShowSearch } from '../data/slice';
import { useElementBoundingBox, useLockScroll } from './hooks';
const mockDispatch = jest.fn();
jest.mock('./hooks');
jest.mock('../data/slice');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
const tabsTopPosition = 128;
function renderComponent(props = {}) {
const { container } = render(<CoursewareSearch {...props} />);
return container;
}
describe('CoursewareSearch', () => {
beforeAll(async () => {
initializeMockApp();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('when rendering normally', () => {
beforeAll(() => {
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
});
beforeEach(() => {
renderComponent();
});
it('Should use useElementBoundingBox() and useLockScroll() hooks', () => {
expect(useElementBoundingBox).toBeCalledTimes(1);
expect(useLockScroll).toBeCalledTimes(1);
});
it('Should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
});
it('Should dispatch setShowSearch(true) when clicking the close button', () => {
const button = screen.getByTestId('courseware-search-close-button');
fireEvent.click(button);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledWith(false);
});
});
describe('when CourseTabsNavigation is not present', () => {
it('Should use "--modal-top-position: 0" if nce element is not present', () => {
useElementBoundingBox.mockImplementation(() => undefined);
renderComponent();
const section = screen.getByTestId('courseware-search-section');
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
});
});
describe('when passing extra props', () => {
it('Should pass on extra props to section element', () => {
renderComponent({ foo: 'bar' });
const section = screen.getByTestId('courseware-search-section');
expect(section).toHaveAttribute('foo', 'bar');
});
});
});

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { useDispatch } from 'react-redux';
import { setShowSearch } from '../data/slice';
import messages from './messages';
import { useCoursewareSearchFeatureFlag } from './hooks';
const CoursewareSearchToggle = ({
intl,
}) => {
const dispatch = useDispatch();
const enabled = useCoursewareSearchFeatureFlag();
if (!enabled) { return null; }
return (
<div className="courseware-searc-toggle">
<Button
variant="tertiary"
size="sm"
className="p-1 mt-2 mr-2 rounded-lg"
aria-label={intl.formatMessage(messages.searchOpenAction)}
onClick={() => dispatch(setShowSearch(true))}
data-testid="courseware-search-open-button"
>
<Icon src={Search} />
</Button>
</div>
);
};
CoursewareSearchToggle.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearchToggle);

View File

@@ -1,67 +0,0 @@
import React from 'react';
import {
act,
fireEvent,
initializeMockApp,
render,
screen,
waitFor,
} from '../../setupTest';
import { fetchCoursewareSearchSettings } from '../data/thunks';
import { setShowSearch } from '../data/slice';
import { CoursewareSearchToggle } from './index';
const mockDispatch = jest.fn();
jest.mock('../data/thunks');
jest.mock('../data/slice');
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
function renderComponent() {
const { container } = render(<CoursewareSearchToggle />);
return container;
}
describe('CoursewareSearchToggle', () => {
beforeAll(async () => {
initializeMockApp();
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should not render when the waffle flag is disabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
await act(async () => renderComponent());
await waitFor(() => {
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('courseware-search-open-button')).not.toBeInTheDocument();
});
});
it('Should render when the waffle flag is enabled', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
await act(async () => renderComponent());
await waitFor(() => {
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('courseware-search-open-button')).toBeInTheDocument();
});
});
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
await act(async () => renderComponent());
const button = await screen.findByTestId('courseware-search-open-button');
fireEvent.click(button);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledTimes(1);
expect(setShowSearch).toHaveBeenCalledWith(true);
});
});

View File

@@ -1,45 +0,0 @@
.courseware-search {
background: white;
position: fixed;
top: var(--modal-top-position, 0);
left: 0;
right: 0;
bottom: 0;
border-top: 1px solid $light-300;
z-index: 200;
&__close {
position: absolute !important; // For some reason it gets overridden
top: 0.5rem;
right: 1rem;
font-size: 1.5rem;
line-height: 1;
}
&__outer-content {
overflow-y: auto;
height: 100%;
}
&__content {
padding-top: 2rem;
padding-left: 1rem;
padding-right: 1rem;
max-width: 42rem;
margin: auto;
h2 {
margin-bottom: 2rem;
}
}
}
@media (min-width: map-get($grid-breakpoints, 'md')) {
.courseware-search__content {
padding-top: 8rem;
}
}
body._search-no-scroll {
overflow-y: hidden;
}

View File

@@ -1,71 +0,0 @@
import { useState, useEffect, useLayoutEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { debounce } from 'lodash';
import { fetchCoursewareSearchSettings } from '../data/thunks';
const DEBOUNCE_WAIT = 100; // ms
export function useCoursewareSearchFeatureFlag() {
const { courseId } = useParams();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
}, [courseId]);
return enabled;
}
export function useCoursewareSearchState() {
const enabled = useCoursewareSearchFeatureFlag();
const show = useSelector(state => state.courseHome.showSearch);
return { show: enabled && show };
}
export function useElementBoundingBox(elementId) {
const [info, setInfo] = useState(undefined);
const element = document.getElementById(elementId);
if (!element) {
console.warn(`useElementBoundingBox(): Unable to find element with id='${elementId}' in the document.`); // eslint-disable-line no-console
return undefined;
}
useLayoutEffect(() => {
// Handler to call on window resize and scroll
function recalculate() {
const bounds = element.getBoundingClientRect();
setInfo(bounds);
}
const debouncedRecalculate = debounce(recalculate, DEBOUNCE_WAIT, { leading: true });
// Add event listener
global.addEventListener('resize', debouncedRecalculate);
global.addEventListener('scroll', debouncedRecalculate);
// Call handler right away so state gets updated with initial window size
debouncedRecalculate();
// Remove event listener on cleanup
return () => {
global.removeEventListener('resize', debouncedRecalculate);
global.removeEventListener('scroll', debouncedRecalculate);
};
}, []);
return info;
}
export function useLockScroll() {
useLayoutEffect(() => {
window.scrollTo(0, 0);
document.body.classList.add('_search-no-scroll');
return () => {
document.body.classList.remove('_search-no-scroll');
};
}, []);
}

View File

@@ -1,187 +0,0 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { fetchCoursewareSearchSettings } from '../data/thunks';
import {
useCoursewareSearchFeatureFlag, useCoursewareSearchState, useElementBoundingBox, useLockScroll,
} from './hooks';
jest.mock('react-redux');
jest.mock('react-router-dom');
jest.mock('../data/thunks');
describe('CoursewareSearch Hooks', () => {
const courses = {
123: { enabled: true },
456: { enabled: false },
};
beforeEach(() => {
fetchCoursewareSearchSettings.mockImplementation((courseId) => Promise.resolve(courses[courseId]));
});
afterEach(() => {
jest.resetAllMocks();
});
describe('useCoursewareSearchFeatureFlag', () => {
const renderTestHook = async (enabled = true) => {
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
let hook;
await act(async () => { (hook = renderHook(() => useCoursewareSearchFeatureFlag())); });
return hook;
};
it('should return true if feature is enabled', async () => {
const hook = await renderTestHook();
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(true);
});
it('should return false if feature is disabled', async () => {
const hook = await renderTestHook(false);
await hook.waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
expect(hook.result.current).toBe(false);
});
});
describe('useCoursewareSearchState', () => {
const renderTestHook = async ({ enabled, showSearch }) => {
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
const mockedStoreState = { courseHome: { showSearch } };
useSelector.mockImplementation(selector => selector(mockedStoreState));
let hook;
await act(async () => { (hook = renderHook(() => useCoursewareSearchState())); });
return hook;
};
it('should return show: true if feature is enabled and showSearch is true', async () => {
const hook = await renderTestHook({ enabled: true, showSearch: true });
expect(hook.result.current).toEqual({ show: true });
});
it('should return show: false in any other case', async () => {
let hook;
hook = await renderTestHook({ enabled: true, showSearch: false });
expect(hook.result.current).toEqual({ show: false });
hook = await renderTestHook({ enabled: false, showSearch: true });
expect(hook.result.current).toEqual({ show: false });
hook = await renderTestHook({ enabled: false, showSearch: false });
expect(hook.result.current).toEqual({ show: false });
});
});
describe('useElementBoundingBox', () => {
let getBoundingClientRectSpy;
const renderTestHook = async ({ elementId, mockedInfo }) => {
getBoundingClientRectSpy = jest.spyOn(document, 'getElementById').mockImplementation(() => (
mockedInfo
? { getBoundingClientRect: () => ({ ...mockedInfo }) }
: undefined
));
let hook;
await act(async () => {
hook = renderHook(() => useElementBoundingBox(elementId));
});
return hook;
};
let addEventListenerSpy;
let removeEventListenerSpy;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
});
describe('when element is present', () => {
const mockedInfo = { top: 128 };
it('should bind resize and scroll events on mount', async () => {
await renderTestHook({ elementId: 'test', mockedInfo });
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
});
it('should unbindbind resize and scroll events when unmounted', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
hook.unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
});
it('should return the element bounding box', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
hook.waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
expect(hook.result.current).toEqual(mockedInfo);
});
it('should call getBoundingClientRect on window resize', async () => {
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
act(() => {
// Trigger the window resize event.
global.innerWidth = 500;
global.dispatchEvent(new Event('resize'));
});
expect(hook.result.current).toEqual(mockedInfo);
});
});
describe('when element is NOT present', () => {
let consoleWarnSpy;
beforeEach(() => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
it('should log a warning and return undefined', async () => {
await renderTestHook({ elementId: 'happiness' });
expect(consoleWarnSpy).toHaveBeenCalledWith("useElementBoundingBox(): Unable to find element with id='happiness' in the document.");
});
});
});
describe('useLockScroll', () => {
const renderTestHook = () => (
renderHook(() => useLockScroll())
);
let windowScrollSpy;
let addBodyClassSpy;
let removeBodyClassSpy;
let hook;
beforeEach(() => {
windowScrollSpy = jest.spyOn(window, 'scrollTo');
addBodyClassSpy = jest.spyOn(document.body.classList, 'add');
removeBodyClassSpy = jest.spyOn(document.body.classList, 'remove');
hook = renderTestHook();
});
it('should perform a scrollTo(0, 0) on mount', () => {
expect(windowScrollSpy).toHaveBeenCalledWith(0, 0);
});
it('should append a _search-no-scroll on mount to the document body', () => {
expect(addBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
});
it('should remove the _search-no-scroll on unmount', () => {
hook.unmount();
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
});
});
});

View File

@@ -1,3 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
export { default as CoursewareSearch } from './CoursewareSearch';

View File

@@ -1,21 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
searchOpenAction: {
id: 'learn.coursewareSerch.openAction',
defaultMessage: 'Search within this course',
description: 'Aria-label for a button that will pop up Courseware Search.',
},
searchCloseAction: {
id: 'learn.coursewareSerch.closeAction',
defaultMessage: 'Close the search form',
description: 'Aria-label for a button that will close Courseware Search.',
},
searchModuleTitle: {
id: 'learn.coursewareSerch.searchModuleTitle',
defaultMessage: 'Search this course',
description: 'Title for the Courseware Search module.',
},
});
export default messages;

View File

@@ -9,7 +9,7 @@ Factory.define('courseHomeMetadata')
is_self_paced: false,
is_enrolled: false,
is_staff: false,
can_view_certificate: true,
can_load_courseware: true,
celebrations: null,
course_access: {
additional_context_user_message: null,
@@ -34,89 +34,91 @@ Factory.define('courseHomeMetadata')
currency_symbol: '$',
},
})
.attr('tabs', ['id', 'host'], (id, host) => [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{
courseId: id,
host,
path: 'course/',
},
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{
courseId: id,
host,
path: 'discussion/forum/',
},
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{
courseId: id,
host,
path: 'course_wiki',
},
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{
courseId: id,
host,
path: 'progress',
},
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{
courseId: id,
host,
path: 'instructor',
},
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{
courseId: id,
host,
path: 'dates',
},
),
]);
.attr(
'tabs', ['id', 'host'], (id, host) => [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{
courseId: id,
host,
path: 'course/',
},
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{
courseId: id,
host,
path: 'discussion/forum/',
},
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{
courseId: id,
host,
path: 'course_wiki',
},
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{
courseId: id,
host,
path: 'progress',
},
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{
courseId: id,
host,
path: 'instructor',
},
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{
courseId: id,
host,
path: 'dates',
},
),
],
);

View File

@@ -28,7 +28,6 @@ Factory.define('outlineTabData')
upgrade_url: `${host}/dashboard`,
}))
.attrs({
course_access_redirect: false,
has_scheduled_content: null,
access_expiration: null,
can_show_upgrade_sock: false,
@@ -36,6 +35,7 @@ Factory.define('outlineTabData')
cert_status: null,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
course_goals: {
goal_options: [],

View File

@@ -6,7 +6,6 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -19,13 +18,10 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canViewCertificate": true,
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
@@ -328,7 +324,6 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -341,13 +336,10 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canViewCertificate": true,
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,
@@ -419,6 +411,7 @@ Object {
"certStatus": null,
"certWebViewUrl": null,
"certificateAvailableDate": null,
"downloadUrl": null,
},
"courseBlocks": Object {
"courses": Object {
@@ -452,6 +445,7 @@ Object {
"effortTime": 15,
"icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true,
"title": "Title of Sequence",
@@ -528,7 +522,6 @@ Object {
"courseId": "course-v1:edX+DemoX+Demo_Course",
"courseStatus": "loaded",
"proctoringPanelStatus": "loading",
"showSearch": false,
"targetUserId": undefined,
"toastBodyLink": null,
"toastBodyText": null,
@@ -541,13 +534,10 @@ Object {
"sequenceMightBeUnit": false,
"sequenceStatus": "loading",
},
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object {
"courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object {
"canViewCertificate": true,
"canLoadCourseware": true,
"celebrations": null,
"courseAccess": Object {
"additionalContextUserMessage": null,

View File

@@ -148,9 +148,12 @@ export function normalizeOutlineBlocks(courseId, blocks) {
effortTime: block.effort_time,
icon: block.icon,
id: block.id,
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
showLink: !!block.lms_web_url,
legacyWebUrl: block.legacy_web_url,
// The presence of an legacy URL for the sequence indicates that we want this
// sequence to be a clickable link in the outline (even though, if the new
// courseware experience is active, we will ignore `legacyWebUrl` and build a
// link to the MFE ourselves).
showLink: !!block.legacy_web_url,
title: block.display_name,
};
break;
@@ -204,18 +207,16 @@ export async function getDatesTabData(courseId) {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const httpErrorStatus = error?.response?.status;
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
}
@@ -265,7 +266,7 @@ export async function getProgressTabData(courseId, targetUserId) {
return camelCasedData;
} catch (error) {
const httpErrorStatus = error?.response?.status;
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {};
@@ -275,12 +276,6 @@ export async function getProgressTabData(courseId, targetUserId) {
// courseAccess in the metadata call, so just ignore this status for now.
return {};
}
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
}
@@ -302,20 +297,6 @@ export async function getProctoringInfoData(courseId, username) {
}
}
export async function getLiveTabIframe(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
@@ -333,23 +314,22 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now();
let tabData;
let { tabData } = {};
let requestTime = Date.now();
let responseTime = requestTime;
try {
requestTime = Date.now();
tabData = await getAuthenticatedHttpClient().get(url);
responseTime = Date.now();
} catch (error) {
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
}
throw error;
}
const responseTime = Date.now();
const {
data,
headers,
@@ -374,7 +354,7 @@ export async function getOutlineTabData(courseId) {
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html || '';
const welcomeMessageHtml = data.welcome_message_html;
return {
accessExpiration,
@@ -445,9 +425,3 @@ export async function unsubscribeFromCourseGoal(token) {
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}
export async function getCoursewareSearchEnabledFlag(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return { enabled: data.enabled || false };
}

View File

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

View File

@@ -21,18 +21,6 @@ describe('Data layer integration tests', () => {
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeAccessDeniedMetadata = Factory.build(
'courseHomeMetadata',
{
id: courseId,
course_access: {
has_access: false,
error_code: 'bad codes',
additional_context_user_message: 'your Codes Are BAD',
},
},
);
let store;
beforeEach(() => {
@@ -67,40 +55,16 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
expect(state).toMatchSnapshot();
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 401 || errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(outlineUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
@@ -111,6 +75,8 @@ describe('Data layer integration tests', () => {
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
@@ -118,31 +84,8 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
expect(state).toMatchSnapshot();
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchProgressTab', () => {
@@ -170,14 +113,7 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
expect(state).toMatchSnapshot();
});
it('Should handle the url including a targetUserId', async () => {
@@ -193,19 +129,6 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2);
});
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(progressUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
});
describe('Test saveCourseGoal', () => {
@@ -250,36 +173,4 @@ describe('Data layer integration tests', () => {
expect(axiosMock.history.post[0].data).toEqual(`{"course_id":"${courseId}"}`);
});
});
describe('Test fetchCoursewareSearchSettings', () => {
it('Should return enabled as true when enabled', async () => {
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
axiosMock.onGet(apiUrl).reply(200, { enabled: true });
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
expect(enabled).toBe(true);
});
it('Should return enabled as false when disabled', async () => {
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
axiosMock.onGet(apiUrl).reply(200, { enabled: false });
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
expect(enabled).toBe(false);
});
it('Should return enabled as false on error', async () => {
const apiUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-search/enabled/`;
axiosMock.onGet(apiUrl).networkError();
const { enabled } = await thunks.fetchCoursewareSearchSettings(courseId);
expect(axiosMock.history.get[0].url).toEqual(apiUrl);
expect(enabled).toBe(false);
});
});
});

View File

@@ -15,7 +15,6 @@ const slice = createSlice({
toastBodyText: null,
toastBodyLink: null,
toastHeader: '',
showSearch: false,
},
reducers: {
fetchProctoringInfoResolved: (state) => {
@@ -48,9 +47,6 @@ const slice = createSlice({
state.toastBodyText = linkText;
state.toastHeader = header;
},
setShowSearch: (state, { payload }) => {
state.showSearch = payload;
},
},
});
@@ -61,7 +57,6 @@ export const {
fetchTabRequest,
fetchTabSuccess,
setCallToActionToast,
setShowSearch,
} = slice.actions;
export const {

View File

@@ -11,8 +11,6 @@ import {
postWeeklyLearningGoal,
postDismissWelcomeMessage,
postRequestCert,
getLiveTabIframe,
getCoursewareSearchEnabledFlag,
} from './api';
import {
@@ -34,38 +32,46 @@ const eventTypes = {
export function fetchTab(courseId, tab, getTabData, targetUserId) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
try {
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadata,
},
}));
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
if (tabDataResult) {
Promise.allSettled([
getCourseHomeCourseMetadata(courseId, 'outline'),
getTabData(courseId, targetUserId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courseHomeMeta',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
// Disable the access-denied path for now - it caused a regression
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else if (tabDataResult || !getTabData) {
dispatch(fetchTabSuccess({
courseId,
targetUserId,
}));
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId, targetUserId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
} catch (e) {
dispatch(fetchTabFailure({ courseId }));
logError(e);
}
});
};
}
@@ -81,14 +87,6 @@ export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function fetchLiveTab(courseId) {
return fetchTab(courseId, 'live', getLiveTabIframe);
}
export function fetchDiscussionTab(courseId) {
return fetchTab(courseId, 'discussion');
}
export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
@@ -140,12 +138,3 @@ export function processEvent(eventData, getTabData) {
}
};
}
export async function fetchCoursewareSearchSettings(courseId) {
try {
const { enabled } = await getCoursewareSearchEnabledFlag(courseId);
return { enabled };
} catch (e) {
return { enabled: false };
}
}

View File

@@ -9,12 +9,14 @@ import Timeline from './timeline/Timeline';
import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store';
/** [MM-P2P] Experiment */
import { initDatesMMP2P } from '../../experiments/mm-p2p';
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
const DatesTab = ({ intl }) => {
function DatesTab({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -28,6 +30,9 @@ const DatesTab = ({ intl }) => {
courseDateBlocks,
} = useModel('dates', courseId);
/** [MM-P2P] Experiment */
const mmp2p = initDatesMMP2P(courseId);
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const logUpgradeLinkClick = () => {
@@ -46,7 +51,8 @@ const DatesTab = ({ intl }) => {
<div role="heading" aria-level="1" className="h2 my-3">
{intl.formatMessage(messages.title)}
</div>
{isSelfPaced && hasDeadlines && (
{ /** [MM-P2P] Experiment */ }
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
<>
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
<SuggestedScheduleHeader />
@@ -54,10 +60,10 @@ const DatesTab = ({ intl }) => {
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
</>
)}
<Timeline />
<Timeline mmp2p={mmp2p} />
</>
);
};
}
DatesTab.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
@@ -32,16 +32,11 @@ describe('DatesTab', () => {
component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Routes>
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
)}
/>
</Routes>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
@@ -346,12 +341,12 @@ describe('DatesTab', () => {
it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
});
});

View File

@@ -17,13 +17,15 @@ import { useModel } from '../../../generic/model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from '../utils';
const Day = ({
function Day({
date,
first,
intl,
items,
last,
}) => {
/** [MM-P2P] Example */
mmp2p,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -35,6 +37,11 @@ const Day = ({
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
/** [MM-P2P] Experiment */
const mmp2pOverride = (
mmp2p.state.isEnabled
&& items.some((item) => item.dateType === 'verified-upgrade-deadline')
);
return (
<li className="dates-day pb-4" data-testid="dates-day">
{/* Top Line */}
@@ -50,7 +57,8 @@ const Day = ({
<div className="d-inline-block ml-3 pl-2">
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
<FormattedDate
value={date}
/** [MM-P2P] Experiment */
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
day="numeric"
month="short"
weekday="short"
@@ -60,7 +68,10 @@ const Day = ({
{badges}
</div>
{items.map((item) => {
const { badges: itemBadges } = getBadgeListAndColor(date, intl, item, items);
/** [MM-P2P] Experiment (conditional) */
const { badges: itemBadges } = mmp2pOverride
? getBadgeListAndColor(new Date(mmp2p.state.upgradeDeadline), intl, item, items)
: getBadgeListAndColor(date, intl, item, items);
const showDueDateTime = item.dateType === 'assignment-due-date';
const showLink = item.link && isLearnerAssignment(item);
@@ -96,14 +107,22 @@ const Day = ({
</OverlayTrigger>
)}
</div>
{item.description && <div className="small mb-2">{item.description}</div>}
{ /** [MM-P2P] Experiment (conditional) */ }
{ mmp2pOverride
? (
<div className="small mb-2">
You are still eligible to upgrade to a Verified Certificate!
&nbsp; Unlock full course access and highlight the knowledge you&apos;ll gain.
</div>
)
: (item.description && <div className="small mb-2">{item.description}</div>)}
</div>
);
})}
</div>
</li>
);
};
}
Day.propTypes = {
date: PropTypes.objectOf(Date).isRequired,
@@ -119,11 +138,25 @@ Day.propTypes = {
title: PropTypes.string,
})).isRequired,
last: PropTypes.bool,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
}),
}),
};
Day.defaultProps = {
first: false,
last: false,
/** [MM-P2P] Experiment */
mmp2p: {
state: {
isEnabled: false,
upgradeDeadline: '',
},
},
};
export default injectIntl(Day);

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
const GoalUnsubscribe = ({ intl }) => {
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -33,7 +33,6 @@ const GoalUnsubscribe = ({ intl }) => {
// as visiting this page is allowed to be done anonymously and without the context of the course.
// The token can be used to connect a user and course, it will just require some post-processing
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // deps=[] to only run once
return (
@@ -49,7 +48,7 @@ const GoalUnsubscribe = ({ intl }) => {
</main>
</>
);
};
}
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,9 +1,7 @@
import React from 'react';
import {
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform';
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';
@@ -26,16 +24,13 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
component = (
<AppProvider store={store} wrapWithRouter={false}>
<AppProvider store={store}>
<UserMessagesProvider>
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}>
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
<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', () => {

View File

@@ -6,7 +6,7 @@ import { Button, Hyperlink } from '@edx/paragon';
import messages from './messages';
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
const ResultPage = ({ courseTitle, error, intl }) => {
function ResultPage({ courseTitle, error, intl }) {
const errorDescription = (
<FormattedMessage
id="learning.goals.unsubscribe.errorDescription"
@@ -44,7 +44,7 @@ const ResultPage = ({ courseTitle, error, intl }) => {
</Button>
</>
);
};
}
ResultPage.defaultProps = {
courseTitle: null,

View File

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

View File

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

View File

@@ -3,18 +3,18 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
const LmsHtmlFragment = ({
export default function LmsHtmlFragment({
className,
html,
title,
...rest
}) => {
}) {
const wholePage = `
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/static/LmsHtmlFragment.css">
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
</head>
<body class="${className}">${html}</body>
<script>
@@ -55,7 +55,7 @@ const LmsHtmlFragment = ({
{...rest}
/>
);
};
}
LmsHtmlFragment.defaultProps = {
className: '',
@@ -66,5 +66,3 @@ LmsHtmlFragment.propTypes = {
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
export default LmsHtmlFragment;

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
@@ -28,7 +28,10 @@ import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
const OutlineTab = ({ intl }) => {
/** [MM-P2P] Experiment */
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const {
courseId,
proctoringPanelStatus,
@@ -66,7 +69,6 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = {
org_key: org,
@@ -101,28 +103,12 @@ const OutlineTab = ({ intl }) => {
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
const location = useLocation();
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const startCourse = currentParams.get('start_course');
if (startCourse === '1') {
sendTrackEvent('enrollment.email.clicked.startcourse', {});
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('start_course');
navigate({
pathname: location.pathname,
search: `?${currentParams.toString()}`,
replace: true,
});
}
}, [location.search]);
return (
<>
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
@@ -130,6 +116,7 @@ const OutlineTab = ({ intl }) => {
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
<AccountActivationAlert />
<div className="col-12">
@@ -141,17 +128,21 @@ const OutlineTab = ({ intl }) => {
/>
</div>
<div className="col col-12 col-md-8">
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
{isSelfPaced && hasDeadlines && (
{ /** [MM-P2P] Experiment (the conditional) */ }
{ !MMP2P.state.isEnabled
&& (
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
)}
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
<>
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
@@ -194,27 +185,35 @@ const OutlineTab = ({ intl }) => {
/>
)}
<CourseTools />
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
)}
<CourseDates
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CourseDates />
<CourseHandouts />
</div>
)}
</div>
</>
);
};
}
OutlineTab.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,7 +2,6 @@
* @jest-environment jsdom
*/
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
@@ -52,14 +51,9 @@ describe('Outline Tab', () => {
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
}
async function fetchAndRender(path = '') {
async function fetchAndRender() {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(
<MemoryRouter initialEntries={[path]}>
<OutlineTab />
</MemoryRouter>,
{ store },
));
await act(async () => render(<OutlineTab />, { store }));
}
beforeEach(async () => {
@@ -119,11 +113,11 @@ describe('Outline Tab', () => {
// Click to expand section
userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'));
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section
userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'));
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
});
it('displays correct icon for complete assignment', async () => {
@@ -144,8 +138,25 @@ describe('Outline Tab', () => {
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
});
it('SequenceLink displays link', async () => {
it('SequenceLink displays points to legacy courseware', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: false,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence');
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
});
it('SequenceLink displays points to courseware MFE', async () => {
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
setMetadata({
can_load_courseware: true,
});
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
@@ -344,26 +355,6 @@ describe('Outline Tab', () => {
expect(spy).toHaveBeenCalledTimes(0);
});
it('post goal via query param', async () => {
setTabData({
course_goals: {
weekly_learning_goal_enabled: true,
},
});
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?weekly_goal=3');
expect(spy).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.setgoal', {});
});
it('emit start course event via query param', async () => {
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?start_course=1');
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.startcourse', {});
});
describe('weekly learning goal is not set', () => {
beforeEach(async () => {
setTabData({
@@ -383,25 +374,25 @@ describe('Outline Tab', () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
});
it.each([
{ level: 'Casual', days: 1 },
{ level: 'Regular', days: 3 },
{ level: 'Intense', days: 5 },
])('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it.each`
level | days
${'Casual'} | ${1}
${'Regular'} | ${3}
${'Intense'} | ${5}
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it('shows and hides subscribe to reminders additional text', async () => {
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
@@ -577,7 +568,7 @@ describe('Outline Tab', () => {
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
expect(instructorToolbar).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
@@ -668,6 +659,7 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
cert_web_view_url: null,
certificate_available_date: tomorrow.toISOString(),
download_url: null,
},
}, {
date_blocks: [
@@ -695,6 +687,7 @@ describe('Outline Tab', () => {
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
@@ -763,6 +756,7 @@ describe('Outline Tab', () => {
cert_data: {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
@@ -789,16 +783,57 @@ describe('Outline Tab', () => {
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
courserun_key: courseId,
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: courseId,
is_staff: false,
org_key: 'edX',
});
});
it('tracks unverified cert button', async () => {
sendTrackEvent.mockClear();
const now = new Date();
@@ -809,6 +844,7 @@ describe('Outline Tab', () => {
cert_data: {
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
cert_web_view_url: null,
download_url: null,
},
}, {
date_blocks: [
@@ -835,14 +871,12 @@ describe('Outline Tab', () => {
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
},
);
});
});
});
@@ -898,6 +932,7 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: 'certificate/testuuid',
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
@@ -923,6 +958,7 @@ describe('Outline Tab', () => {
cert_status: CERT_STATUS_TYPE.REQUESTING,
cert_web_view_url: null,
certificate_available_date: null,
download_url: null,
},
}, {
date_blocks: [
@@ -939,6 +975,33 @@ describe('Outline Tab', () => {
});
});
describe('Certificate (pdf) Complete Alert', () => {
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
setMetadata({ is_enrolled: true });
setTabData({
cert_data: {
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
cert_web_view_url: null,
certificate_available_date: null,
download_url: 'download/url',
},
}, {
date_blocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
});
describe('Proctoring Info Panel', () => {
const onboardingReleaseDate = new Date();
onboardingReleaseDate.setDate(new Date().getDate() - 7);
@@ -1021,22 +1084,6 @@ describe('Outline Tab', () => {
});
it('displays expiration warning', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expiration warning for other course', async () => {
const expirationDate = new Date();
// This message will render if the expiration date is within 28 days; set the date 10 days in future
expirationDate.setTime(expirationDate.getTime() + 864800000);
@@ -1048,23 +1095,7 @@ describe('Outline Tab', () => {
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});
it('displays expired', async () => {
const expirationDate = new Date();
// This message appears after expiration, set the date 10 days in the past
expirationDate.setTime(expirationDate.getTime() - 864800000);
axiosMock.onGet(proctoringInfoUrl).reply(200, {
onboarding_status: 'verified',
onboarding_link: 'test',
expiration_date: expirationDate.toString(),
onboarding_release_date: onboardingReleaseDate.toISOString(),
});
await fetchAndRender();
await screen.findByText('This course contains proctored exams');
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
});

View File

@@ -12,13 +12,13 @@ import { useModel } from '../../generic/model-store';
import genericMessages from '../../generic/messages';
import messages from './messages';
const Section = ({
function Section({
courseId,
defaultOpen,
expand,
intl,
section,
}) => {
}) {
const {
complete,
sequenceIds,
@@ -38,7 +38,6 @@ const Section = ({
useEffect(() => {
setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sectionTitle = (
@@ -110,7 +109,7 @@ const Section = ({
</Collapsible>
</li>
);
};
}
Section.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Hyperlink } from '@edx/paragon';
import {
FormattedMessage,
FormattedTime,
@@ -16,73 +17,38 @@ import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
const SequenceLink = ({
function SequenceLink({
id,
intl,
courseId,
first,
sequence,
}) => {
}) {
const {
complete,
description,
due,
legacyWebUrl,
showLink,
title,
} = sequence;
const {
userTimezone,
} = useModel('outline', courseId);
const {
canLoadCourseware,
} = useModel('courseHomeMeta', courseId);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
const coursewareUrl = (
canLoadCourseware
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
);
const displayTitle = showLink ? coursewareUrl : title;
const dueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-set"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
const noDueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-not-set"
defaultMessage="{description}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
@@ -114,15 +80,35 @@ const SequenceLink = ({
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage}
</small>
</div>
{due && (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
<FormattedMessage
id="learning.outline.sequence-due"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
</small>
</div>
)}
</div>
</li>
);
};
}
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,

View File

@@ -25,7 +25,7 @@ export const CERT_STATUS_TYPE = {
UNVERIFIED: 'unverified',
};
const CertificateStatusAlert = ({ intl, payload }) => {
function CertificateStatusAlert({ intl, payload }) {
const dispatch = useDispatch();
const {
certificateAvailableDate,
@@ -33,6 +33,7 @@ const CertificateStatusAlert = ({ intl, payload }) => {
courseEndDate,
courseId,
certURL,
isWebCert,
userTimezone,
org,
notPassingCourseEnded,
@@ -78,7 +79,11 @@ const CertificateStatusAlert = ({ intl, payload }) => {
);
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
if (isWebCert) {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
} else {
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
}
alertProps.buttonVisible = true;
alertProps.buttonLink = certURL;
alertProps.buttonAction = () => {
@@ -189,7 +194,7 @@ const CertificateStatusAlert = ({ intl, payload }) => {
)}
</AlertWrapper>
);
};
}
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,
@@ -199,6 +204,7 @@ CertificateStatusAlert.propTypes = {
courseEndDate: PropTypes.string,
courseId: PropTypes.string,
certURL: PropTypes.string,
isWebCert: PropTypes.bool,
userTimezone: PropTypes.string,
org: PropTypes.string,
notPassingCourseEnded: PropTypes.bool,

View File

@@ -51,8 +51,10 @@ function useCertificateStatusAlert(courseId) {
certStatus,
certWebViewUrl,
certificateAvailableDate,
downloadUrl,
} = certData || {};
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
const isWebCert = downloadUrl === null;
const isVerifiedEnrollmentMode = (
enrollmentMode !== null
&& enrollmentMode !== undefined
@@ -61,6 +63,9 @@ function useCertificateStatusAlert(courseId) {
let certURL = '';
if (certWebViewUrl) {
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
} else if (downloadUrl) {
// PDF Certificate
certURL = downloadUrl;
}
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
@@ -75,22 +80,22 @@ function useCertificateStatusAlert(courseId) {
&& hasEnded
&& !userHasPassingGrade
);
const payload = useMemo(() => ({
const payload = {
certificateAvailableDate,
certURL,
certStatus,
courseId,
courseEndDate: endBlock && endBlock.date,
userTimezone,
isWebCert,
org,
notPassingCourseEnded,
tabs,
}), [certStatus, certURL, certificateAvailableDate, courseId,
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);
};
useAlert(isVisible || notPassingCourseEnded, {
code: 'clientCertificateStatusAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});

View File

@@ -3,17 +3,15 @@ import PropTypes from 'prop-types';
import {
FormattedDate,
FormattedMessage,
FormattedRelativeTime,
FormattedRelative,
FormattedTime,
} from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
const CourseEndAlert = ({ payload }) => {
function CourseEndAlert({ payload }) {
const {
description,
endDate,
@@ -22,19 +20,16 @@ const CourseEndAlert = ({ payload }) => {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
let msg;
const delta = new Date(endDate) - new Date();
const timeRemaining = (
<FormattedRelativeTime
<FormattedRelative
key="timeRemaining"
value={delta / 1000}
numeric="auto"
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
updateIntervalInSeconds={YEAR_SEC}
value={endDate}
{...timezoneFormatArgs}
/>
);
let msg;
const delta = new Date(endDate) - new Date();
if (delta < DAY_MS) {
const courseEndTime = (
<FormattedTime
@@ -88,7 +83,7 @@ const CourseEndAlert = ({ payload }) => {
{description}
</Alert>
);
};
}
CourseEndAlert.propTypes = {
payload: PropTypes.shape({

View File

@@ -23,15 +23,15 @@ export function useCourseEndAlert(courseId) {
const endDate = endBlock ? new Date(endBlock.date) : null;
const delta = endBlock ? endDate - new Date() : 0;
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
const payload = useMemo(() => ({
const payload = {
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,
}), [endBlock, userTimezone]);
};
useAlert(isVisible, {
code: 'clientCourseEndAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});

View File

@@ -14,7 +14,7 @@ import outlineMessages from '../../messages';
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
import { useModel } from '../../../../generic/model-store';
const PrivateCourseAlert = ({ intl, payload }) => {
function PrivateCourseAlert({ intl, payload }) {
const {
anonymousUser,
canEnroll,
@@ -100,7 +100,7 @@ const PrivateCourseAlert = ({ intl, payload }) => {
)}
</Alert>
);
};
}
PrivateCourseAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -18,16 +18,16 @@ export function usePrivateCourseAlert(courseId) {
* 2. the user is authenticated.
* */
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
const payload = useMemo(() => ({
const payload = {
anonymousUser: authenticatedUser === null,
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId,
}), [authenticatedUser, courseId, outline]);
};
useAlert(isVisible, {
code: 'clientPrivateCourseAlert',
dismissible: false,
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-private-alerts',
type: ALERT_TYPES.WELCOME,
});

View File

@@ -3,7 +3,7 @@ import { Alert, Button } from '@edx/paragon';
import React from 'react';
import PropTypes from 'prop-types';
const ScheduledContentAlert = ({ payload }) => {
function ScheduledContentAlert({ payload }) {
const {
datesTabLink,
} = payload;
@@ -38,7 +38,7 @@ const ScheduledContentAlert = ({ payload }) => {
</div>
</Alert>
);
};
}
ScheduledContentAlert.propTypes = {
payload: PropTypes.shape({

View File

@@ -20,12 +20,12 @@ const useScheduledContentAlert = (courseId) => {
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
);
const { isEnrolled } = useModel('courseHomeMeta', courseId);
const payload = useMemo(() => ({
const payload = {
datesTabLink,
}), [datesTabLink]);
};
useAlert(hasScheduledContent && isEnrolled, {
code: 'ScheduledContentAlert',
payload,
payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts',
});

View File

@@ -231,11 +231,6 @@ const messages = defineMessages({
defaultMessage: 'Expiring Soon',
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
},
expiredProctoringStatus: {
id: 'learning.proctoringPanel.status.expired',
defaultMessage: 'Expired',
description: 'A label to indicate that proctortrack onboarding exam has expired',
},
proctoringCurrentStatus: {
id: 'learning.proctoringPanel.status',
defaultMessage: 'Current Onboarding Status:',
@@ -283,14 +278,9 @@ const messages = defineMessages({
},
expiringSoonProctoringMessage: {
id: 'learning.proctoringPanel.message.expiringSoon',
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
},
expiredProctoringMessage: {
id: 'learning.proctoringPanel.message.expired',
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
},
proctoringPanelGeneralInfo: {
id: 'learning.proctoringPanel.generalInfo',
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -7,9 +8,11 @@ import DateSummary from '../DateSummary';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const CourseDates = ({
function CourseDates({
intl,
}) => {
/** [MM-P2P] Experiment */
mmp2p,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -37,6 +40,8 @@ const CourseDates = ({
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/** [MM-P2P] Experiment */
mmp2p={mmp2p}
/>
))}
</ol>
@@ -46,10 +51,17 @@ const CourseDates = ({
</div>
</section>
);
};
}
CourseDates.propTypes = {
intl: intlShape.isRequired,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({}),
};
CourseDates.defaultProps = {
/** [MM-P2P] Experiment */
mmp2p: {},
};
export default injectIntl(CourseDates);

View File

@@ -7,7 +7,7 @@ import LmsHtmlFragment from '../LmsHtmlFragment';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const CourseHandouts = ({ intl }) => {
function CourseHandouts({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -29,7 +29,7 @@ const CourseHandouts = ({ intl }) => {
/>
</section>
);
};
}
CourseHandouts.propTypes = {
intl: intlShape.isRequired,

View File

@@ -14,7 +14,7 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
const CourseTools = ({ intl }) => {
function CourseTools({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -79,7 +79,7 @@ const CourseTools = ({ intl }) => {
</ul>
</section>
);
};
}
CourseTools.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,35 +2,35 @@ import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
const FlagButton = ({
function FlagButton({
buttonIcon,
title,
text,
handleSelect,
isSelected,
}) => (
<button
type="button"
className={classnames(
'flag-button row w-100 align-content-between m-1.5 py-3.5',
isSelected ? 'flag-button-selected' : '',
)}
aria-checked={isSelected}
role="radio"
onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
>
<div className="row w-100 m-0 justify-content-center pb-1">
{buttonIcon}
</div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
{title}
</div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
{text}
</div>
</button>
);
}) {
return (
<button
type="button"
className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5',
isSelected ? 'flag-button-selected' : '')}
aria-checked={isSelected}
role="radio"
onClick={() => handleSelect()}
data-testid={`weekly-learning-goal-input-${title}`}
>
<div className="row w-100 m-0 justify-content-center pb-1">
{buttonIcon}
</div>
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
{title}
</div>
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
{text}
</div>
</button>
);
}
FlagButton.propTypes = {
buttonIcon: PropTypes.element.isRequired,

View File

@@ -9,12 +9,12 @@ import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
import FlagButton from './FlagButton';
import messages from '../messages';
const LearningGoalButton = ({
function LearningGoalButton({
level,
isSelected,
handleSelect,
intl,
}) => {
}) {
const buttonDetails = {
casual: {
daysPerWeek: 1,
@@ -47,7 +47,7 @@ const LearningGoalButton = ({
isSelected={isSelected}
/>
);
};
}
LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired,

View File

@@ -10,7 +10,7 @@ import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
const ProctoringInfoPanel = ({ intl }) => {
function ProctoringInfoPanel({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -35,7 +35,6 @@ const ProctoringInfoPanel = ({ intl }) => {
error: 'error',
otherCourseApproved: 'otherCourseApproved',
expiringSoon: 'expiringSoon',
expired: 'expired',
};
function getReadableStatusClass(examStatus) {
@@ -55,14 +54,9 @@ const ProctoringInfoPanel = ({ intl }) => {
return readableClass;
}
function isCurrentlySubmitted(examStatus) {
const SUBMITTED_STATES = ['submitted', 'second_review_required'];
return SUBMITTED_STATES.includes(examStatus);
}
function isSubmissionRequired(examStatus) {
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
return !OK_STATES.includes(examStatus);
function isNotYetSubmitted(examStatus) {
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
return !NO_SHOW_STATES.includes(examStatus);
}
function isNotYetReleased(examReleaseDate) {
@@ -83,19 +77,11 @@ const ProctoringInfoPanel = ({ intl }) => {
return borderClass;
}
function isExpired(dateString) {
// Returns true if the expiration date has passed
const today = new Date();
const expirationDateObject = new Date(dateString);
return today >= expirationDateObject.getTime();
}
function isExpiringSoon(dateString) {
// Returns true if the expiration date is within 28 days
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
const today = new Date();
const expirationDateObject = new Date(dateString);
return today > expirationDateObject.getTime() - twentyeightDays;
return today > expirationDateObject.getTime() - 2419200000;
}
useEffect(() => {
@@ -110,9 +96,7 @@ const ProctoringInfoPanel = ({ intl }) => {
setStatus(response.onboarding_status);
setLink(response.onboarding_link);
const expirationDate = response.expiration_date;
if (expirationDate && isExpired(expirationDate)) {
setReadableStatus(getReadableStatusClass('expired'));
} else if (expirationDate && isExpiringSoon(expirationDate)) {
if (expirationDate && isExpiringSoon(expirationDate)) {
setReadableStatus(getReadableStatusClass('expiringSoon'));
} else {
setReadableStatus(getReadableStatusClass(response.onboarding_status));
@@ -128,7 +112,6 @@ const ProctoringInfoPanel = ({ intl }) => {
.finally(() => {
dispatch(fetchProctoringInfoResolved());
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let onboardingExamButton = null;
@@ -171,7 +154,6 @@ const ProctoringInfoPanel = ({ intl }) => {
}
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{ showInfoPanel && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
@@ -193,17 +175,17 @@ const ProctoringInfoPanel = ({ intl }) => {
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
<>
<p>
{!isCurrentlySubmitted(status) && (
{isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfo)
)}
{isCurrentlySubmitted(status) && (
{!isNotYetSubmitted(status) && (
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
)}
</p>
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
</>
)}
{isSubmissionRequired(readableStatus) && (
{isNotYetSubmitted(status) && (
onboardingExamButton
)}
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
@@ -214,7 +196,7 @@ const ProctoringInfoPanel = ({ intl }) => {
)}
</>
);
};
}
ProctoringInfoPanel.propTypes = {
intl: intlShape.isRequired,

View File

@@ -7,7 +7,7 @@ import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const StartOrResumeCourseCard = ({ intl }) => {
function StartOrResumeCourseCard({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -56,11 +56,10 @@ const StartOrResumeCourseCard = ({ intl }) => {
)}
/>
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<Card.Footer><></></Card.Footer>
</Card>
);
};
}
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Form, Card, Icon } from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -15,11 +13,11 @@ import { saveWeeklyLearningGoal } from '../../data';
import { useModel } from '../../../generic/model-store';
import './FlagButton.scss';
const WeeklyLearningGoalCard = ({
function WeeklyLearningGoalCard({
daysPerWeek,
subscribedToReminders,
intl,
}) => {
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -34,9 +32,8 @@ const WeeklyLearningGoalCard = ({
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
// eslint-disable-next-line react/prop-types
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
const location = useLocation();
const handleSelect = (days, triggeredFromEmail = false) => {
function handleSelect(days) {
// Set the subscription button if this is the first time selecting a goal
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
setGetReminderSelected(selectReminders);
@@ -50,11 +47,8 @@ const WeeklyLearningGoalCard = ({
num_days: days,
reminder_selected: selectReminders,
});
if (triggeredFromEmail) {
sendTrackEvent('enrollment.email.clicked.setgoal', {});
}
}
};
}
function handleSubscribeToReminders(event) {
const isGetReminderChecked = event.target.checked;
@@ -71,22 +65,6 @@ const WeeklyLearningGoalCard = ({
}
}
useEffect(() => {
const currentParams = new URLSearchParams(location.search);
const weeklyGoal = Number(currentParams.get('weekly_goal'));
if ([1, 3, 5].includes(weeklyGoal)) {
handleSelect(weeklyGoal, true);
// Deleting the weekly_goal query param as it only needs to be set once
// whenever passed in query params.
currentParams.delete('weekly_goal');
history.replace({
search: currentParams.toString(),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
return (
<Card
id="courseHome-weeklyLearningGoal"
@@ -147,7 +125,7 @@ const WeeklyLearningGoalCard = ({
)}
</Card>
);
};
}
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,

View File

@@ -11,22 +11,21 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { dismissWelcomeMessage } from '../../data/thunks';
const WelcomeMessage = ({ courseId, intl }) => {
function WelcomeMessage({ courseId, intl }) {
const {
welcomeMessageHtml,
} = useModel('outline', courseId);
if (!welcomeMessageHtml) {
return null;
}
const [display, setDisplay] = useState(true);
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
const dispatch = useDispatch();
if (!welcomeMessageHtml) {
return null;
}
return (
<Alert
data-testid="alert-container-welcome"
@@ -70,7 +69,7 @@ const WelcomeMessage = ({ courseId, intl }) => {
</TransitionReplace>
</Alert>
);
};
}
WelcomeMessage.propTypes = {
courseId: PropTypes.string.isRequired,

View File

@@ -9,7 +9,7 @@ import { useModel } from '../../generic/model-store';
import messages from './messages';
const ProgressHeader = ({ intl }) => {
function ProgressHeader({ intl }) {
const {
courseId,
targetUserId,
@@ -26,16 +26,18 @@ const ProgressHeader = ({ intl }) => {
: intl.formatMessage(messages.progressHeader);
return (
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
<h1>{pageTitle}</h1>
{administrator && studioUrl && (
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
{intl.formatMessage(messages.studioLink)}
</Button>
)}
</div>
<>
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
<h1>{pageTitle}</h1>
{administrator && studioUrl && (
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
{intl.formatMessage(messages.studioLink)}
</Button>
)}
</div>
</>
);
};
}
ProgressHeader.propTypes = {
intl: intlShape.isRequired,

View File

@@ -12,13 +12,13 @@ import RelatedLinks from './related-links/RelatedLinks';
import { useModel } from '../../generic/model-store';
const ProgressTab = () => {
function ProgressTab() {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
gradesFeatureIsFullyLocked, disableProgressGraph,
gradesFeatureIsFullyLocked,
} = useModel('progress', courseId);
const applyLockedOverlay = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
@@ -38,7 +38,7 @@ const ProgressTab = () => {
<div className="row w-100 m-0">
{/* Main body */}
<div className="col-12 col-md-8 p-0">
{!disableProgressGraph && <CourseCompletion />}
<CourseCompletion />
{!wideScreen && <CertificateStatus />}
<CourseGrade />
<div className={`grades my-4 p-4 rounded raised-card ${applyLockedOverlay}`} aria-hidden={gradesFeatureIsFullyLocked}>
@@ -55,6 +55,6 @@ const ProgressTab = () => {
</div>
</>
);
};
}
export default ProgressTab;

View File

@@ -31,9 +31,6 @@ describe('Progress Tab', () => {
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
@@ -959,6 +956,49 @@ describe('Progress Tab', () => {
});
});
it('Displays download link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
track_variant: 'audit',
grade_variant: 'passing',
certificate_status_variant: 'earned_downloadable',
});
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
fireEvent.click(downloadCertificateLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
certificate_status_variant: 'earned_downloadable',
});
});
it('Displays webview link', async () => {
setTabData({
certificate_data: {
@@ -1180,66 +1220,6 @@ describe('Progress Tab', () => {
await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows certificate_available_date if learner is passing', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: {
cert_status: 'earned_but_not_available',
certificate_available_date: overmorrow.toISOString(),
},
});
await fetchAndRender();
expect(screen.getByText('Certificate status'));
expect(screen.getByText(
overmorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
{ exact: false },
)).toBeInTheDocument();
});
});
describe('Credit Information', () => {
@@ -1303,7 +1283,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true });
@@ -1316,7 +1296,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
});
});
@@ -1332,7 +1312,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
});
it('does not render banner when not masquerading', async () => {
setMetadata({
@@ -1344,7 +1324,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
});
});

View File

@@ -14,7 +14,7 @@ import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../
import { requestCert } from '../../data/thunks';
import messages from './messages';
const CertificateStatus = ({ intl }) => {
function CertificateStatus({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -22,8 +22,6 @@ const CertificateStatus = ({ intl }) => {
const {
isEnrolled,
org,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId);
const {
@@ -47,8 +45,6 @@ const CertificateStatus = ({ intl }) => {
hasScheduledContent,
isEnrolled,
userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
);
const eventProperties = {
@@ -61,11 +57,12 @@ const CertificateStatus = ({ intl }) => {
let certStatus;
let certWebViewUrl;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
let downloadUrl;
if (certificateData) {
certStatus = certificateData.certStatus;
certWebViewUrl = certificateData.certWebViewUrl;
downloadUrl = certificateData.downloadUrl;
}
let certCase;
@@ -141,10 +138,15 @@ const CertificateStatus = ({ intl }) => {
values={{ dashboardLink, profileLink }}
/>
);
if (certWebViewUrl) {
certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton);
} else if (downloadUrl) {
certEventName = 'earned_downloadable';
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadableButton);
}
break;
@@ -176,22 +178,10 @@ const CertificateStatus = ({ intl }) => {
}
break;
// This code shouldn't be hit but coding defensively since switch expects a default statement
default:
// if user completes a course before certificates are available, treat it as notAvailable
// regardless of passing or nonpassing status
if (!canViewCertificate) {
certCase = 'notAvailable';
endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
} else {
certCase = null;
certEventName = 'no_certificate_status';
}
certCase = null;
certEventName = 'no_certificate_status';
break;
}
}
@@ -206,7 +196,6 @@ const CertificateStatus = ({ intl }) => {
grade_variant: gradeEventName,
certificate_status_variant: certEventName,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!certCase) {
@@ -258,7 +247,7 @@ const CertificateStatus = ({ intl }) => {
</Card>
</section>
);
};
}
CertificateStatus.propTypes = {
intl: intlShape.isRequired,

View File

@@ -61,6 +61,11 @@ const messages = defineMessages({
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.',
description: 'Recommending an action for learner when course certificate is available',
},
downloadableButton: {
id: 'progress.certificateStatus.downloadableButton',
defaultMessage: 'Download my certificate',
description: 'Button text when learner certifcate status is downloadable',
},
viewableButton: {
id: 'progress.certificateStatus.viewableButton',
defaultMessage: 'View my certificate',
@@ -71,11 +76,6 @@ const messages = defineMessages({
defaultMessage: 'Certificate status',
description: 'Header text when the certifcate is not available',
},
notAvailableEndDateBody: {
id: 'progress.certificateBody.notAvailable.endDate',
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
description: 'Shown for learners who have finished a course before grades and certificates are available.',
},
upgradeHeader: {
id: 'progress.certificateStatus.upgradeHeader',
defaultMessage: 'Earn a certificate',

View File

@@ -6,13 +6,13 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from './messages';
const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) => {
const [showCompletePopover, setShowCompletePopover] = useState(false);
function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
if (!completePercentage) {
return null;
}
const [showCompletePopover, setShowCompletePopover] = useState(false);
const completeSegmentOffset = (3.6 * completePercentage) / 8;
let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
@@ -78,7 +78,7 @@ const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) =>
)}
</g>
);
};
}
CompleteDonutSegment.propTypes = {
completePercentage: PropTypes.number.isRequired,

View File

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

View File

@@ -4,21 +4,23 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import CompletionDonutChart from './CompletionDonutChart';
import messages from './messages';
const CourseCompletion = ({ intl }) => (
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
<p className="small">
{intl.formatMessage(messages.completionBody)}
</p>
function CourseCompletion({ intl }) {
return (
<section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0">
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
<p className="small">
{intl.formatMessage(messages.completionBody)}
</p>
</div>
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
<CompletionDonutChart />
</div>
</div>
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
<CompletionDonutChart />
</div>
</div>
</section>
);
</section>
);
}
CourseCompletion.propTypes = {
intl: intlShape.isRequired,

View File

@@ -6,13 +6,13 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from './messages';
const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
function IncompleteDonutSegment({ incompletePercentage, intl }) {
if (!incompletePercentage) {
return null;
}
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
const incompleteTooltipDegree = incompletePercentage < 100 ? incompleteSegmentOffset : 0;
@@ -49,7 +49,7 @@ const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
</OverlayTrigger>
</g>
);
};
}
IncompleteDonutSegment.propTypes = {
incompletePercentage: PropTypes.number.isRequired,

View File

@@ -6,7 +6,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
const LockedDonutSegment = ({ intl, lockedPercentage }) => {
function LockedDonutSegment({ intl, lockedPercentage }) {
const [showLockedPopover, setShowLockedPopover] = useState(false);
if (!lockedPercentage) {
@@ -62,7 +62,7 @@ const LockedDonutSegment = ({ intl, lockedPercentage }) => {
</OverlayTrigger>
</g>
);
};
}
LockedDonutSegment.propTypes = {
intl: intlShape.isRequired,

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