Compare commits
5 Commits
bw/hackath
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72bce9e652 | ||
|
|
66482ab23a | ||
|
|
03e0f41692 | ||
|
|
8e1904a235 | ||
|
|
88485a0f77 |
3
.env
3
.env
@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL=''
|
|||||||
ENABLE_JUMPNAV='true'
|
ENABLE_JUMPNAV='true'
|
||||||
ENABLE_NOTICES=''
|
ENABLE_NOTICES=''
|
||||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||||
EXAMS_BASE_URL=''
|
|
||||||
FAVICON_URL=''
|
FAVICON_URL=''
|
||||||
IGNORED_ERROR_REGEX=''
|
IGNORED_ERROR_REGEX=''
|
||||||
INSIGHTS_BASE_URL=''
|
INSIGHTS_BASE_URL=''
|
||||||
@@ -29,8 +28,6 @@ LOGO_WHITE_URL=''
|
|||||||
LEGACY_THEME_NAME=''
|
LEGACY_THEME_NAME=''
|
||||||
MARKETING_SITE_BASE_URL=''
|
MARKETING_SITE_BASE_URL=''
|
||||||
ORDER_HISTORY_URL=''
|
ORDER_HISTORY_URL=''
|
||||||
PROCTORED_EXAM_FAQ_URL=''
|
|
||||||
PROCTORED_EXAM_RULES_URL=''
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||||
SEARCH_CATALOG_URL=''
|
SEARCH_CATALOG_URL=''
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
|||||||
ENABLE_JUMPNAV='true'
|
ENABLE_JUMPNAV='true'
|
||||||
ENABLE_NOTICES=''
|
ENABLE_NOTICES=''
|
||||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||||
EXAMS_BASE_URL='http://localhost:18740'
|
|
||||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
IGNORED_ERROR_REGEX=''
|
IGNORED_ERROR_REGEX=''
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||||
@@ -29,8 +28,6 @@ LEGACY_THEME_NAME=''
|
|||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
PORT=2000
|
PORT=2000
|
||||||
PROCTORED_EXAM_FAQ_URL=''
|
|
||||||
PROCTORED_EXAM_RULES_URL=''
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
|||||||
ENABLE_JUMPNAV='true'
|
ENABLE_JUMPNAV='true'
|
||||||
ENABLE_NOTICES=''
|
ENABLE_NOTICES=''
|
||||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||||
EXAMS_BASE_URL='http://localhost:18740'
|
|
||||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||||
IGNORED_ERROR_REGEX=''
|
IGNORED_ERROR_REGEX=''
|
||||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||||
@@ -29,8 +28,6 @@ LEGACY_THEME_NAME=''
|
|||||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
PORT=2000
|
PORT=2000
|
||||||
PROCTORED_EXAM_FAQ_URL=''
|
|
||||||
PROCTORED_EXAM_RULES_URL=''
|
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||||
SEGMENT_KEY=''
|
SEGMENT_KEY=''
|
||||||
|
|||||||
22
.eslintrc.js
22
.eslintrc.js
@@ -1,17 +1,11 @@
|
|||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
||||||
const { createConfig } = require('@edx/frontend-build');
|
const { createConfig } = require('@edx/frontend-build');
|
||||||
|
|
||||||
const config = createConfig('eslint', {
|
module.exports = createConfig('eslint', {
|
||||||
rules: {
|
overrides: [{
|
||||||
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release.
|
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
|
||||||
'react-hooks/rules-of-hooks': 'off',
|
rules: {
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
'import/named': 'off',
|
||||||
'import/no-extraneous-dependencies': '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 = config;
|
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ jobs:
|
|||||||
secrets:
|
secrets:
|
||||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
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 }}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# This workflow runs when a comment is made on the ticket
|
|
||||||
# If the comment starts with "label: " it tries to apply
|
|
||||||
# the label indicated in rest of comment.
|
|
||||||
# If the comment starts with "remove label: ", it tries
|
|
||||||
# to remove the indicated label.
|
|
||||||
# Note: Labels are allowed to have spaces and this script does
|
|
||||||
# not parse spaces (as often a space is legitimate), so the command
|
|
||||||
# "label: really long lots of words label" will apply the
|
|
||||||
# label "really long lots of words label"
|
|
||||||
|
|
||||||
name: Allows for the adding and removing of labels via comment
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add_remove_labels:
|
|
||||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
|
||||||
|
|
||||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
commitlint:
|
commitlint:
|
||||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||||
|
|||||||
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -1,13 +0,0 @@
|
|||||||
#check package-lock file version
|
|
||||||
|
|
||||||
name: Lockfile Version check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
version-check:
|
|
||||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
|
||||||
12
.github/workflows/self-assign-issue.yml
vendored
12
.github/workflows/self-assign-issue.yml
vendored
@@ -1,12 +0,0 @@
|
|||||||
# This workflow runs when a comment is made on the ticket
|
|
||||||
# If the comment starts with "assign me" it assigns the author to the
|
|
||||||
# ticket (case insensitive)
|
|
||||||
|
|
||||||
name: Assign comment author to ticket if they say "assign me"
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
self_assign_by_comment:
|
|
||||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
|
||||||
12
.github/workflows/update-browserslist-db.yml
vendored
12
.github/workflows/update-browserslist-db.yml
vendored
@@ -1,12 +0,0 @@
|
|||||||
name: Update Browserslist DB
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * 1'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-browserslist:
|
|
||||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
|
||||||
2
Makefile
2
Makefile
@@ -1,5 +1,5 @@
|
|||||||
export TRANSIFEX_RESOURCE=frontend-app-learning
|
export TRANSIFEX_RESOURCE=frontend-app-learning
|
||||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||||
|
|
||||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||||
i18n = ./src/i18n
|
i18n = ./src/i18n
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
|
|||||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
|
||||||
|
|
||||||
Development
|
Development
|
||||||
-----------
|
-----------
|
||||||
@@ -23,7 +23,7 @@ Development
|
|||||||
Start Devstack
|
Start Devstack
|
||||||
^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^
|
||||||
|
|
||||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||||
|
|
||||||
- Run ``make dev.up.lms``
|
- Run ``make dev.up.lms``
|
||||||
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||||
@@ -52,7 +52,7 @@ file (which is git-ignored) that defines where to find your local modules, for i
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||||
|
|
||||||
Deployment
|
Deployment
|
||||||
----------
|
----------
|
||||||
|
|||||||
@@ -9,6 +9,4 @@ module.exports = createConfig('jest', {
|
|||||||
'src/i18n',
|
'src/i18n',
|
||||||
'src/.*\\.exp\\..*',
|
'src/.*\\.exp\\..*',
|
||||||
],
|
],
|
||||||
testTimeout: 30000,
|
|
||||||
testEnvironment: 'jsdom'
|
|
||||||
});
|
});
|
||||||
|
|||||||
34822
package-lock.json
generated
34822
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@@ -4,7 +4,7 @@
|
|||||||
"description": "Frontend learning application.",
|
"description": "Frontend learning application.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/openedx/frontend-app-learning.git"
|
"url": "git+https://github.com/edx/frontend-app-learning.git"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"extends @edx/browserslist-config"
|
"extends @edx/browserslist-config"
|
||||||
@@ -21,58 +21,55 @@
|
|||||||
},
|
},
|
||||||
"author": "edX",
|
"author": "edX",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
|
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||||
"@edx/frontend-component-footer": "11.6.3",
|
"@edx/frontend-component-footer": "10.2.2",
|
||||||
"@edx/frontend-component-header": "3.6.4",
|
"@edx/frontend-component-header": "2.4.6",
|
||||||
"@edx/frontend-lib-special-exams": "2.10.0",
|
"@edx/frontend-lib-special-exams": "1.16.3",
|
||||||
"@edx/frontend-platform": "4.1.0",
|
"@edx/frontend-platform": "1.15.6",
|
||||||
"@edx/paragon": "20.28.4",
|
"@edx/paragon": "19.14.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||||
"@fortawesome/react-fontawesome": "0.1.18",
|
"@fortawesome/react-fontawesome": "0.1.18",
|
||||||
"@popperjs/core": "2.11.6",
|
"@popperjs/core": "2.11.5",
|
||||||
"@reduxjs/toolkit": "1.8.1",
|
"@reduxjs/toolkit": "1.8.1",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.1",
|
||||||
"core-js": "3.22.2",
|
"core-js": "3.21.1",
|
||||||
"history": "5.3.0",
|
|
||||||
"html-react-parser": "^3.0.15",
|
|
||||||
"js-cookie": "3.0.1",
|
"js-cookie": "3.0.1",
|
||||||
"lodash.camelcase": "4.3.0",
|
"lodash.camelcase": "4.3.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"query-string": "7.1.3",
|
|
||||||
"react": "16.14.0",
|
"react": "16.14.0",
|
||||||
"react-dom": "16.14.0",
|
"react-dom": "16.14.0",
|
||||||
"react-helmet": "6.1.0",
|
"react-helmet": "6.1.0",
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.8",
|
||||||
"react-router": "5.2.1",
|
"react-router": "5.2.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
"react-share": "4.4.1",
|
"react-share": "4.4.0",
|
||||||
"redux": "4.1.2",
|
"redux": "4.1.2",
|
||||||
"regenerator-runtime": "0.13.11",
|
"regenerator-runtime": "0.13.9",
|
||||||
"reselect": "4.1.7",
|
"reselect": "4.1.5",
|
||||||
"truncate-html": "1.0.4",
|
"truncate-html": "1.0.4",
|
||||||
"util": "0.12.5"
|
"util": "0.12.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/browserslist-config": "1.1.1",
|
"@edx/browserslist-config": "1.0.2",
|
||||||
"@edx/frontend-build": "^12.4.15",
|
"@edx/frontend-build": "9.1.4",
|
||||||
"@edx/reactifex": "2.1.1",
|
"@edx/reactifex": "1.1.0",
|
||||||
"@pact-foundation/pact": "9.17.3",
|
"@pact-foundation/pact": "9.17.3",
|
||||||
"@testing-library/jest-dom": "5.16.5",
|
"@testing-library/jest-dom": "5.16.4",
|
||||||
"@testing-library/react": "12.1.5",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/user-event": "13.5.0",
|
"@testing-library/user-event": "13.5.0",
|
||||||
"axios-mock-adapter": "1.20.0",
|
"axios-mock-adapter": "1.20.0",
|
||||||
"copy-webpack-plugin": "^11.0.0",
|
"codecov": "3.8.3",
|
||||||
"es-check": "6.2.1",
|
"es-check": "6.2.1",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
"jest": "27.5.1",
|
"jest": "27.5.1",
|
||||||
|
|||||||
@@ -5,12 +5,5 @@
|
|||||||
"patch": {
|
"patch": {
|
||||||
"automerge": true
|
"automerge": true
|
||||||
},
|
},
|
||||||
"rebaseStalePrs": true,
|
"rebaseStalePrs": true
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchPackagePatterns": ["@edx"],
|
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
|
||||||
"automerge": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import {
|
import {
|
||||||
@@ -7,8 +8,18 @@ import { Alert, Hyperlink } from '@edx/paragon';
|
|||||||
import { Info } from '@edx/paragon/icons';
|
import { Info } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import messages from './messages';
|
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 {
|
const {
|
||||||
accessExpiration,
|
accessExpiration,
|
||||||
courseId,
|
courseId,
|
||||||
@@ -28,6 +39,13 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
|||||||
upgradeUrl,
|
upgradeUrl,
|
||||||
} = accessExpiration;
|
} = accessExpiration;
|
||||||
|
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
if (showMMP2P) {
|
||||||
|
return (
|
||||||
|
<AccessExpirationAlertMMP2P payload={payload} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const logClick = () => {
|
const logClick = () => {
|
||||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||||
org_key: org,
|
org_key: org,
|
||||||
@@ -116,7 +134,7 @@ const AccessExpirationAlert = ({ intl, payload }) => {
|
|||||||
{deadlineMessage}
|
{deadlineMessage}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
AccessExpirationAlert.propTypes = {
|
AccessExpirationAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { FormattedDate, injectIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Alert, Hyperlink } from '@edx/paragon';
|
||||||
|
import { Info } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
function AccessExpirationAlertMMP2P({ payload }) {
|
||||||
|
const {
|
||||||
|
accessExpiration,
|
||||||
|
userTimezone,
|
||||||
|
} = payload;
|
||||||
|
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||||
|
|
||||||
|
if (!accessExpiration) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
expirationDate,
|
||||||
|
upgradeDeadline,
|
||||||
|
upgradeUrl,
|
||||||
|
} = accessExpiration;
|
||||||
|
|
||||||
|
let deadlineMessage = null;
|
||||||
|
const formatDate = (val, key) => (
|
||||||
|
<FormattedDate
|
||||||
|
key={`accessExpiration.${key}`}
|
||||||
|
day="numeric"
|
||||||
|
month="short"
|
||||||
|
year="numeric"
|
||||||
|
value={val}
|
||||||
|
{...timezoneFormatArgs}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upgradeDeadline && upgradeUrl) {
|
||||||
|
deadlineMessage = (
|
||||||
|
<>
|
||||||
|
Upgrade by {formatDate(upgradeDeadline, 'upgradeDesc')} to unlock unlimited access to all course activities, including graded assignments.
|
||||||
|
|
||||||
|
<Hyperlink
|
||||||
|
className="font-weight-bold"
|
||||||
|
style={{ textDecoration: 'underline' }}
|
||||||
|
destination={upgradeUrl}
|
||||||
|
>
|
||||||
|
{messages.upgradeNow.defaultMessage}
|
||||||
|
</Hyperlink>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="info" icon={Info}>
|
||||||
|
<span className="font-weight-bold">
|
||||||
|
Unlock full course content by {formatDate(upgradeDeadline, 'upgradeTitle')}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
{deadlineMessage}
|
||||||
|
<br />
|
||||||
|
You lose all access to the first two weeks of scheduled content
|
||||||
|
on {formatDate(expirationDate, 'expirationBody')}.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessExpirationAlertMMP2P.propTypes = {
|
||||||
|
payload: PropTypes.shape({
|
||||||
|
accessExpiration: PropTypes.shape({
|
||||||
|
expirationDate: PropTypes.string.isRequired,
|
||||||
|
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||||
|
upgradeDeadline: PropTypes.string,
|
||||||
|
upgradeUrl: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
userTimezone: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(AccessExpirationAlertMMP2P);
|
||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||||
import { PageBanner } from '@edx/paragon';
|
import { PageBanner } from '@edx/paragon';
|
||||||
|
|
||||||
const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
function AccessExpirationMasqueradeBanner({ payload }) {
|
||||||
const {
|
const {
|
||||||
expirationDate,
|
expirationDate,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
@@ -27,7 +27,7 @@ const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
|||||||
/>
|
/>
|
||||||
</PageBanner>
|
</PageBanner>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
AccessExpirationMasqueradeBanner.propTypes = {
|
AccessExpirationMasqueradeBanner.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpira
|
|||||||
|
|
||||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||||
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
|
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
accessExpiration,
|
accessExpiration,
|
||||||
courseId,
|
courseId,
|
||||||
org,
|
org,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
analyticsPageName,
|
analyticsPageName,
|
||||||
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientAccessExpirationAlert',
|
code: 'clientAccessExpirationAlert',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic,
|
topic,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,14 +34,14 @@ export function useAccessExpirationMasqueradeBanner(courseId, tab) {
|
|||||||
|
|
||||||
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
|
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
|
||||||
const expirationDate = accessExpiration && accessExpiration.expirationDate;
|
const expirationDate = accessExpiration && accessExpiration.expirationDate;
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
expirationDate,
|
expirationDate,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
}), [expirationDate, userTimezone]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientAccessExpirationMasqueradeBanner',
|
code: 'clientAccessExpirationMasqueradeBanner',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'instructor-toolbar-alerts',
|
topic: 'instructor-toolbar-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Alert, Hyperlink } from '@edx/paragon';
|
|
||||||
import { WarningFilled } from '@edx/paragon/icons';
|
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import genericMessages from './messages';
|
|
||||||
|
|
||||||
const ActiveEnterpriseAlert = ({ intl, payload }) => {
|
|
||||||
const { text, courseId } = payload;
|
|
||||||
const changeActiveEnterprise = (
|
|
||||||
<Hyperlink
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
destination={
|
|
||||||
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
|
|
||||||
`${global.location.origin}/course/${courseId}/home`,
|
|
||||||
)}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
|
|
||||||
</Hyperlink>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert variant="warning" icon={WarningFilled}>
|
|
||||||
{text}
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.activeEnterprise.alert"
|
|
||||||
description="Prompts the user to log-in with the correct enterprise to access the course content."
|
|
||||||
defaultMessage=" {changeActiveEnterprise}."
|
|
||||||
values={{
|
|
||||||
changeActiveEnterprise,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ActiveEnterpriseAlert.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
payload: PropTypes.shape({
|
|
||||||
text: PropTypes.string,
|
|
||||||
courseId: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ActiveEnterpriseAlert);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import {
|
|
||||||
initializeTestStore, render, screen,
|
|
||||||
} from '../../setupTest';
|
|
||||||
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
|
|
||||||
|
|
||||||
describe('ActiveEnterpriseAlert', () => {
|
|
||||||
const mockData = {
|
|
||||||
payload: {
|
|
||||||
text: 'test message',
|
|
||||||
courseId: 'test-course-id',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
beforeAll(async () => {
|
|
||||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows alert message and links', () => {
|
|
||||||
render(<ActiveEnterpriseAlert {...mockData} />);
|
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
|
||||||
import { useModel } from '../../generic/model-store';
|
|
||||||
|
|
||||||
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
|
|
||||||
|
|
||||||
export default function useActiveEnterpriseAlert(courseId) {
|
|
||||||
const { courseAccess } = useModel('courseHomeMeta', courseId);
|
|
||||||
/**
|
|
||||||
* This alert should render if
|
|
||||||
* 1. course access code is incorrect_active_enterprise
|
|
||||||
*/
|
|
||||||
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
|
|
||||||
|
|
||||||
const payload = useMemo(() => ({
|
|
||||||
text: courseAccess && courseAccess.userMessage,
|
|
||||||
courseId,
|
|
||||||
}), [courseAccess, courseId]);
|
|
||||||
useAlert(isVisible, {
|
|
||||||
code: 'clientActiveEnterpriseAlert',
|
|
||||||
topic: 'outline',
|
|
||||||
dismissible: false,
|
|
||||||
type: ALERT_TYPES.ERROR,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import useActiveEnterpriseAlert from './hooks';
|
|
||||||
|
|
||||||
export default useActiveEnterpriseAlert;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
changeActiveEnterpriseLowercase: {
|
|
||||||
id: 'learning.activeEnterprise.change.alert',
|
|
||||||
defaultMessage: 'change enterprise now',
|
|
||||||
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
import {
|
import {
|
||||||
FormattedDate,
|
FormattedDate,
|
||||||
FormattedMessage,
|
FormattedMessage,
|
||||||
FormattedRelativeTime,
|
FormattedRelative,
|
||||||
FormattedTime,
|
FormattedTime,
|
||||||
} from '@edx/frontend-platform/i18n';
|
} from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert } from '@edx/paragon';
|
||||||
@@ -11,11 +11,9 @@ import { Info } from '@edx/paragon/icons';
|
|||||||
|
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
|
|
||||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
|
||||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
|
||||||
|
|
||||||
const CourseStartAlert = ({ payload }) => {
|
function CourseStartAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = payload;
|
} = payload;
|
||||||
@@ -27,17 +25,15 @@ const CourseStartAlert = ({ payload }) => {
|
|||||||
|
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||||
|
|
||||||
const delta = new Date(startDate) - new Date();
|
|
||||||
const timeRemaining = (
|
const timeRemaining = (
|
||||||
<FormattedRelativeTime
|
<FormattedRelative
|
||||||
key="timeRemaining"
|
key="timeRemaining"
|
||||||
value={delta / 1000}
|
value={startDate}
|
||||||
numeric="auto"
|
|
||||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
|
||||||
updateIntervalInSeconds={YEAR_SEC}
|
|
||||||
{...timezoneFormatArgs}
|
{...timezoneFormatArgs}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const delta = new Date(startDate) - new Date();
|
||||||
if (delta < DAY_MS) {
|
if (delta < DAY_MS) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="info" icon={Info}>
|
<Alert variant="info" icon={Info}>
|
||||||
@@ -94,7 +90,7 @@ const CourseStartAlert = ({ payload }) => {
|
|||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseStartAlert.propTypes = {
|
CourseStartAlert.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PageBanner } from '@edx/paragon';
|
|||||||
|
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
|
|
||||||
const CourseStartMasqueradeBanner = ({ payload }) => {
|
function CourseStartMasqueradeBanner({ payload }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = payload;
|
} = payload;
|
||||||
@@ -33,7 +33,7 @@ const CourseStartMasqueradeBanner = ({ payload }) => {
|
|||||||
/>
|
/>
|
||||||
</PageBanner>
|
</PageBanner>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseStartMasqueradeBanner.propTypes = {
|
CourseStartMasqueradeBanner.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useModel } from '../../generic/model-store';
|
|||||||
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
||||||
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
|
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
|
||||||
|
|
||||||
function IsStartDateInFuture(courseId) {
|
function isStartDateInFuture(courseId) {
|
||||||
const {
|
const {
|
||||||
start,
|
start,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courseHomeMeta', courseId);
|
||||||
@@ -20,15 +20,15 @@ function useCourseStartAlert(courseId) {
|
|||||||
isEnrolled,
|
isEnrolled,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courseHomeMeta', courseId);
|
||||||
|
|
||||||
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
|
const isVisible = isEnrolled && isStartDateInFuture(courseId);
|
||||||
|
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
courseId,
|
courseId,
|
||||||
}), [courseId]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientCourseStartAlert',
|
code: 'clientCourseStartAlert',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'outline-course-alerts',
|
topic: 'outline-course-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,15 +42,15 @@ export function useCourseStartMasqueradeBanner(courseId, tab) {
|
|||||||
isMasquerading,
|
isMasquerading,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courseHomeMeta', courseId);
|
||||||
|
|
||||||
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId);
|
const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
|
||||||
|
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
courseId,
|
courseId,
|
||||||
}), [courseId]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientCourseStartMasqueradeBanner',
|
code: 'clientCourseStartMasqueradeBanner',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'instructor-toolbar-alerts',
|
topic: 'instructor-toolbar-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useModel } from '../../generic/model-store';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import useEnrollClickHandler from './clickHook';
|
import useEnrollClickHandler from './clickHook';
|
||||||
|
|
||||||
const EnrollmentAlert = ({ intl, payload }) => {
|
function EnrollmentAlert({ intl, payload }) {
|
||||||
const {
|
const {
|
||||||
canEnroll,
|
canEnroll,
|
||||||
courseId,
|
courseId,
|
||||||
@@ -55,7 +55,7 @@ const EnrollmentAlert = ({ intl, payload }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
EnrollmentAlert.propTypes = {
|
EnrollmentAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function useEnrollClickHandler(courseId, orgId, successText) {
|
|||||||
});
|
});
|
||||||
global.location.reload();
|
global.location.reload();
|
||||||
});
|
});
|
||||||
}, [addFlash, courseId, orgId, successText]);
|
}, [courseId]);
|
||||||
|
|
||||||
return { enrollClickHandler, loading };
|
return { enrollClickHandler, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ export function useEnrollmentAlert(courseId) {
|
|||||||
* 3. the course is private.
|
* 3. the course is private.
|
||||||
*/
|
*/
|
||||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||||
courseId,
|
courseId,
|
||||||
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
||||||
isStaff: course && course.isStaff,
|
isStaff: course && course.isStaff,
|
||||||
}), [course, courseId, outline]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientEnrollmentAlert',
|
code: 'clientEnrollmentAlert',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'outline',
|
topic: 'outline',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
|
|||||||
import { sendActivationEmail } from '../../courseware/data';
|
import { sendActivationEmail } from '../../courseware/data';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const AccountActivationAlert = ({
|
function AccountActivationAlert({
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showSpinner, setShowSpinner] = useState(false);
|
const [showSpinner, setShowSpinner] = useState(false);
|
||||||
const [showCheck, setShowCheck] = useState(false);
|
const [showCheck, setShowCheck] = useState(false);
|
||||||
@@ -123,7 +123,7 @@ const AccountActivationAlert = ({
|
|||||||
{children()}
|
{children()}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
AccountActivationAlert.propTypes = {
|
AccountActivationAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { WarningFilled } from '@edx/paragon/icons';
|
|||||||
|
|
||||||
import genericMessages from '../../generic/messages';
|
import genericMessages from '../../generic/messages';
|
||||||
|
|
||||||
const LogistrationAlert = ({ intl }) => {
|
function LogistrationAlert({ intl }) {
|
||||||
const signIn = (
|
const signIn = (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
style={{ textDecoration: 'underline' }}
|
style={{ textDecoration: 'underline' }}
|
||||||
@@ -41,7 +41,7 @@ const LogistrationAlert = ({ intl }) => {
|
|||||||
/>
|
/>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
LogistrationAlert.propTypes = {
|
LogistrationAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
|
|||||||
|
|
||||||
if (entranceExamPassed) {
|
if (entranceExamPassed) {
|
||||||
entranceExamText = intl.formatMessage(
|
entranceExamText = intl.formatMessage(
|
||||||
messages.entranceExamTextPassed,
|
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
|
||||||
{ entranceExamCurrentScore: entranceExamCurrentScore * 100 },
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
|
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Factory.define('courseHomeMetadata')
|
|||||||
is_self_paced: false,
|
is_self_paced: false,
|
||||||
is_enrolled: false,
|
is_enrolled: false,
|
||||||
is_staff: false,
|
is_staff: false,
|
||||||
can_view_certificate: true,
|
can_load_courseware: true,
|
||||||
celebrations: null,
|
celebrations: null,
|
||||||
course_access: {
|
course_access: {
|
||||||
additional_context_user_message: null,
|
additional_context_user_message: null,
|
||||||
@@ -34,89 +34,91 @@ Factory.define('courseHomeMetadata')
|
|||||||
currency_symbol: '$',
|
currency_symbol: '$',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.attr('tabs', ['id', 'host'], (id, host) => [
|
.attr(
|
||||||
Factory.build(
|
'tabs', ['id', 'host'], (id, host) => [
|
||||||
'tab',
|
Factory.build(
|
||||||
{
|
'tab',
|
||||||
title: 'Course',
|
{
|
||||||
priority: 0,
|
title: 'Course',
|
||||||
slug: 'courseware',
|
priority: 0,
|
||||||
type: 'courseware',
|
slug: 'courseware',
|
||||||
},
|
type: 'courseware',
|
||||||
{
|
},
|
||||||
courseId: id,
|
{
|
||||||
host,
|
courseId: id,
|
||||||
path: 'course/',
|
host,
|
||||||
},
|
path: 'course/',
|
||||||
),
|
},
|
||||||
Factory.build(
|
),
|
||||||
'tab',
|
Factory.build(
|
||||||
{
|
'tab',
|
||||||
title: 'Discussion',
|
{
|
||||||
priority: 1,
|
title: 'Discussion',
|
||||||
slug: 'discussion',
|
priority: 1,
|
||||||
type: 'discussion',
|
slug: 'discussion',
|
||||||
},
|
type: 'discussion',
|
||||||
{
|
},
|
||||||
courseId: id,
|
{
|
||||||
host,
|
courseId: id,
|
||||||
path: 'discussion/forum/',
|
host,
|
||||||
},
|
path: 'discussion/forum/',
|
||||||
),
|
},
|
||||||
Factory.build(
|
),
|
||||||
'tab',
|
Factory.build(
|
||||||
{
|
'tab',
|
||||||
title: 'Wiki',
|
{
|
||||||
priority: 2,
|
title: 'Wiki',
|
||||||
slug: 'wiki',
|
priority: 2,
|
||||||
type: 'wiki',
|
slug: 'wiki',
|
||||||
},
|
type: 'wiki',
|
||||||
{
|
},
|
||||||
courseId: id,
|
{
|
||||||
host,
|
courseId: id,
|
||||||
path: 'course_wiki',
|
host,
|
||||||
},
|
path: 'course_wiki',
|
||||||
),
|
},
|
||||||
Factory.build(
|
),
|
||||||
'tab',
|
Factory.build(
|
||||||
{
|
'tab',
|
||||||
title: 'Progress',
|
{
|
||||||
priority: 3,
|
title: 'Progress',
|
||||||
slug: 'progress',
|
priority: 3,
|
||||||
type: 'progress',
|
slug: 'progress',
|
||||||
},
|
type: 'progress',
|
||||||
{
|
},
|
||||||
courseId: id,
|
{
|
||||||
host,
|
courseId: id,
|
||||||
path: 'progress',
|
host,
|
||||||
},
|
path: 'progress',
|
||||||
),
|
},
|
||||||
Factory.build(
|
),
|
||||||
'tab',
|
Factory.build(
|
||||||
{
|
'tab',
|
||||||
title: 'Instructor',
|
{
|
||||||
priority: 4,
|
title: 'Instructor',
|
||||||
slug: 'instructor',
|
priority: 4,
|
||||||
type: 'instructor',
|
slug: 'instructor',
|
||||||
},
|
type: 'instructor',
|
||||||
{
|
},
|
||||||
courseId: id,
|
{
|
||||||
host,
|
courseId: id,
|
||||||
path: 'instructor',
|
host,
|
||||||
},
|
path: 'instructor',
|
||||||
),
|
},
|
||||||
Factory.build(
|
),
|
||||||
'tab',
|
Factory.build(
|
||||||
{
|
'tab',
|
||||||
title: 'Dates',
|
{
|
||||||
priority: 5,
|
title: 'Dates',
|
||||||
slug: 'dates',
|
priority: 5,
|
||||||
type: 'dates',
|
slug: 'dates',
|
||||||
},
|
type: 'dates',
|
||||||
{
|
},
|
||||||
courseId: id,
|
{
|
||||||
host,
|
courseId: id,
|
||||||
path: 'dates',
|
host,
|
||||||
},
|
path: 'dates',
|
||||||
),
|
},
|
||||||
]);
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Factory.define('outlineTabData')
|
|||||||
cert_status: null,
|
cert_status: null,
|
||||||
cert_web_view_url: null,
|
cert_web_view_url: null,
|
||||||
certificate_available_date: null,
|
certificate_available_date: null,
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
course_goals: {
|
course_goals: {
|
||||||
goal_options: [],
|
goal_options: [],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Object {
|
|||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courseHomeMeta": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||||
"canViewCertificate": true,
|
"canLoadCourseware": true,
|
||||||
"celebrations": null,
|
"celebrations": null,
|
||||||
"courseAccess": Object {
|
"courseAccess": Object {
|
||||||
"additionalContextUserMessage": null,
|
"additionalContextUserMessage": null,
|
||||||
@@ -339,7 +339,7 @@ Object {
|
|||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courseHomeMeta": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||||
"canViewCertificate": true,
|
"canLoadCourseware": true,
|
||||||
"celebrations": null,
|
"celebrations": null,
|
||||||
"courseAccess": Object {
|
"courseAccess": Object {
|
||||||
"additionalContextUserMessage": null,
|
"additionalContextUserMessage": null,
|
||||||
@@ -411,6 +411,7 @@ Object {
|
|||||||
"certStatus": null,
|
"certStatus": null,
|
||||||
"certWebViewUrl": null,
|
"certWebViewUrl": null,
|
||||||
"certificateAvailableDate": null,
|
"certificateAvailableDate": null,
|
||||||
|
"downloadUrl": null,
|
||||||
},
|
},
|
||||||
"courseBlocks": Object {
|
"courseBlocks": Object {
|
||||||
"courses": Object {
|
"courses": Object {
|
||||||
@@ -444,6 +445,7 @@ Object {
|
|||||||
"effortTime": 15,
|
"effortTime": 15,
|
||||||
"icon": null,
|
"icon": null,
|
||||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||||
|
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
|
||||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||||
"showLink": true,
|
"showLink": true,
|
||||||
"title": "Title of Sequence",
|
"title": "Title of Sequence",
|
||||||
@@ -535,7 +537,7 @@ Object {
|
|||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courseHomeMeta": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||||
"canViewCertificate": true,
|
"canLoadCourseware": true,
|
||||||
"celebrations": null,
|
"celebrations": null,
|
||||||
"courseAccess": Object {
|
"courseAccess": Object {
|
||||||
"additionalContextUserMessage": null,
|
"additionalContextUserMessage": null,
|
||||||
|
|||||||
@@ -148,9 +148,12 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
|||||||
effortTime: block.effort_time,
|
effortTime: block.effort_time,
|
||||||
icon: block.icon,
|
icon: block.icon,
|
||||||
id: block.id,
|
id: block.id,
|
||||||
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
|
legacyWebUrl: block.legacy_web_url,
|
||||||
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
// The presence of an legacy URL for the sequence indicates that we want this
|
||||||
showLink: !!block.lms_web_url,
|
// sequence to be a clickable link in the outline (even though, if the new
|
||||||
|
// courseware experience is active, we will ignore `legacyWebUrl` and build a
|
||||||
|
// link to the MFE ourselves).
|
||||||
|
showLink: !!block.legacy_web_url,
|
||||||
title: block.display_name,
|
title: block.display_name,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
@@ -205,6 +208,10 @@ export async function getDatesTabData(courseId) {
|
|||||||
return camelCaseObject(data);
|
return camelCaseObject(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { httpErrorStatus } = error && error.customAttributes;
|
const { httpErrorStatus } = error && error.customAttributes;
|
||||||
|
if (httpErrorStatus === 404) {
|
||||||
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
if (httpErrorStatus === 401) {
|
if (httpErrorStatus === 401) {
|
||||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||||
// courseAccess in the metadata call, so just ignore this status for now.
|
// courseAccess in the metadata call, so just ignore this status for now.
|
||||||
@@ -290,20 +297,6 @@ export async function getProctoringInfoData(courseId, username) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLiveTabIframe(courseId) {
|
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
|
|
||||||
try {
|
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
const { httpErrorStatus } = error && error.customAttributes;
|
|
||||||
if (httpErrorStatus === 404) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||||
@@ -321,9 +314,21 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
|||||||
|
|
||||||
export async function getOutlineTabData(courseId) {
|
export async function getOutlineTabData(courseId) {
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||||
const requestTime = Date.now();
|
let { tabData } = {};
|
||||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
let requestTime = Date.now();
|
||||||
const responseTime = Date.now();
|
let responseTime = requestTime;
|
||||||
|
try {
|
||||||
|
requestTime = Date.now();
|
||||||
|
tabData = await getAuthenticatedHttpClient().get(url);
|
||||||
|
responseTime = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
const { httpErrorStatus } = error && error.customAttributes;
|
||||||
|
if (httpErrorStatus === 404) {
|
||||||
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -349,7 +354,7 @@ export async function getOutlineTabData(courseId) {
|
|||||||
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
|
||||||
const userHasPassingGrade = data.user_has_passing_grade;
|
const userHasPassingGrade = data.user_has_passing_grade;
|
||||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||||
const welcomeMessageHtml = data.welcome_message_html || '';
|
const welcomeMessageHtml = data.welcome_message_html;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessExpiration,
|
accessExpiration,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('Course Home Service', () => {
|
|||||||
sku: '8CF08E5',
|
sku: '8CF08E5',
|
||||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
}),
|
}),
|
||||||
|
can_load_courseware: boolean(true),
|
||||||
celebrations: like({
|
celebrations: like({
|
||||||
first_section: false,
|
first_section: false,
|
||||||
streak_length_to_celebrate: null,
|
streak_length_to_celebrate: null,
|
||||||
@@ -105,6 +106,7 @@ describe('Course Home Service', () => {
|
|||||||
sku: '8CF08E5',
|
sku: '8CF08E5',
|
||||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||||
},
|
},
|
||||||
|
canLoadCourseware: true,
|
||||||
celebrations: {
|
celebrations: {
|
||||||
firstSection: false,
|
firstSection: false,
|
||||||
streakLengthToCelebrate: null,
|
streakLengthToCelebrate: null,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
postWeeklyLearningGoal,
|
postWeeklyLearningGoal,
|
||||||
postDismissWelcomeMessage,
|
postDismissWelcomeMessage,
|
||||||
postRequestCert,
|
postRequestCert,
|
||||||
getLiveTabIframe,
|
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,38 +32,46 @@ const eventTypes = {
|
|||||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(fetchTabRequest({ courseId }));
|
dispatch(fetchTabRequest({ courseId }));
|
||||||
try {
|
Promise.allSettled([
|
||||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
|
getCourseHomeCourseMetadata(courseId, 'outline'),
|
||||||
dispatch(addModel({
|
getTabData(courseId, targetUserId),
|
||||||
modelType: 'courseHomeMeta',
|
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||||
model: {
|
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||||
id: courseId,
|
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||||
...courseHomeCourseMetadata,
|
|
||||||
},
|
if (fetchedCourseHomeCourseMetadata) {
|
||||||
}));
|
dispatch(addModel({
|
||||||
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
|
modelType: 'courseHomeMeta',
|
||||||
if (tabDataResult) {
|
model: {
|
||||||
|
id: courseId,
|
||||||
|
...courseHomeCourseMetadataResult.value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
logError(courseHomeCourseMetadataResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchedTabData) {
|
||||||
dispatch(addModel({
|
dispatch(addModel({
|
||||||
modelType: tab,
|
modelType: tab,
|
||||||
model: {
|
model: {
|
||||||
id: courseId,
|
id: courseId,
|
||||||
...tabDataResult,
|
...tabDataResult.value,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
logError(tabDataResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable the access-denied path for now - it caused a regression
|
// Disable the access-denied path for now - it caused a regression
|
||||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
|
||||||
dispatch(fetchTabDenied({ courseId }));
|
dispatch(fetchTabDenied({ courseId }));
|
||||||
} else if (tabDataResult || !getTabData) {
|
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||||
dispatch(fetchTabSuccess({
|
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||||
courseId,
|
} else {
|
||||||
targetUserId,
|
dispatch(fetchTabFailure({ courseId }));
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
});
|
||||||
dispatch(fetchTabFailure({ courseId }));
|
|
||||||
logError(e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +87,6 @@ export function fetchOutlineTab(courseId) {
|
|||||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchLiveTab(courseId) {
|
|
||||||
return fetchTab(courseId, 'live', getLiveTabIframe);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchDiscussionTab(courseId) {
|
|
||||||
return fetchTab(courseId, 'discussion');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dismissWelcomeMessage(courseId) {
|
export function dismissWelcomeMessage(courseId) {
|
||||||
return async () => postDismissWelcomeMessage(courseId);
|
return async () => postDismissWelcomeMessage(courseId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import Timeline from './timeline/Timeline';
|
|||||||
import { fetchDatesTab } from '../data';
|
import { fetchDatesTab } from '../data';
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
|
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
import { initDatesMMP2P } from '../../experiments/mm-p2p';
|
||||||
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
|
import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedScheduleHeader';
|
||||||
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
|
||||||
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
|
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
|
||||||
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
|
||||||
|
|
||||||
const DatesTab = ({ intl }) => {
|
function DatesTab({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -28,6 +30,9 @@ const DatesTab = ({ intl }) => {
|
|||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
} = useModel('dates', courseId);
|
} = useModel('dates', courseId);
|
||||||
|
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
const mmp2p = initDatesMMP2P(courseId);
|
||||||
|
|
||||||
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||||
|
|
||||||
const logUpgradeLinkClick = () => {
|
const logUpgradeLinkClick = () => {
|
||||||
@@ -46,7 +51,8 @@ const DatesTab = ({ intl }) => {
|
|||||||
<div role="heading" aria-level="1" className="h2 my-3">
|
<div role="heading" aria-level="1" className="h2 my-3">
|
||||||
{intl.formatMessage(messages.title)}
|
{intl.formatMessage(messages.title)}
|
||||||
</div>
|
</div>
|
||||||
{isSelfPaced && hasDeadlines && (
|
{ /** [MM-P2P] Experiment */ }
|
||||||
|
{isSelfPaced && hasDeadlines && !mmp2p.state.isEnabled && (
|
||||||
<>
|
<>
|
||||||
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
<ShiftDatesAlert model="dates" fetch={fetchDatesTab} />
|
||||||
<SuggestedScheduleHeader />
|
<SuggestedScheduleHeader />
|
||||||
@@ -54,10 +60,10 @@ const DatesTab = ({ intl }) => {
|
|||||||
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
<UpgradeToShiftDatesAlert logUpgradeLinkClick={logUpgradeLinkClick} model="dates" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Timeline />
|
<Timeline mmp2p={mmp2p} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
DatesTab.propTypes = {
|
DatesTab.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -341,12 +341,12 @@ describe('DatesTab', () => {
|
|||||||
|
|
||||||
it('redirects to the home page when unauthenticated', async () => {
|
it('redirects to the home page when unauthenticated', async () => {
|
||||||
await renderDenied('authentication_required');
|
await renderDenied('authentication_required');
|
||||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects to the home page when unenrolled', async () => {
|
it('redirects to the home page when unenrolled', async () => {
|
||||||
await renderDenied('enrollment_required');
|
await renderDenied('enrollment_required');
|
||||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ import { useModel } from '../../../generic/model-store';
|
|||||||
import { getBadgeListAndColor } from './badgelist';
|
import { getBadgeListAndColor } from './badgelist';
|
||||||
import { isLearnerAssignment } from '../utils';
|
import { isLearnerAssignment } from '../utils';
|
||||||
|
|
||||||
const Day = ({
|
function Day({
|
||||||
date,
|
date,
|
||||||
first,
|
first,
|
||||||
intl,
|
intl,
|
||||||
items,
|
items,
|
||||||
last,
|
last,
|
||||||
}) => {
|
/** [MM-P2P] Example */
|
||||||
|
mmp2p,
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -35,6 +37,11 @@ const Day = ({
|
|||||||
|
|
||||||
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
|
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 (
|
return (
|
||||||
<li className="dates-day pb-4" data-testid="dates-day">
|
<li className="dates-day pb-4" data-testid="dates-day">
|
||||||
{/* Top Line */}
|
{/* Top Line */}
|
||||||
@@ -50,7 +57,8 @@ const Day = ({
|
|||||||
<div className="d-inline-block ml-3 pl-2">
|
<div className="d-inline-block ml-3 pl-2">
|
||||||
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
|
<div className="row w-100 m-0 mb-1 align-items-center text-primary-700" data-testid="dates-header">
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={date}
|
/** [MM-P2P] Experiment */
|
||||||
|
value={mmp2pOverride ? mmp2p.state.upgradeDeadline : date}
|
||||||
day="numeric"
|
day="numeric"
|
||||||
month="short"
|
month="short"
|
||||||
weekday="short"
|
weekday="short"
|
||||||
@@ -60,7 +68,10 @@ const Day = ({
|
|||||||
{badges}
|
{badges}
|
||||||
</div>
|
</div>
|
||||||
{items.map((item) => {
|
{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 showDueDateTime = item.dateType === 'assignment-due-date';
|
||||||
const showLink = item.link && isLearnerAssignment(item);
|
const showLink = item.link && isLearnerAssignment(item);
|
||||||
@@ -96,14 +107,22 @@ const Day = ({
|
|||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.description && <div className="small mb-2">{item.description}</div>}
|
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||||
|
{ mmp2pOverride
|
||||||
|
? (
|
||||||
|
<div className="small mb-2">
|
||||||
|
You are still eligible to upgrade to a Verified Certificate!
|
||||||
|
Unlock full course access and highlight the knowledge you'll gain.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (item.description && <div className="small mb-2">{item.description}</div>)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
Day.propTypes = {
|
Day.propTypes = {
|
||||||
date: PropTypes.objectOf(Date).isRequired,
|
date: PropTypes.objectOf(Date).isRequired,
|
||||||
@@ -119,11 +138,25 @@ Day.propTypes = {
|
|||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
})).isRequired,
|
})).isRequired,
|
||||||
last: PropTypes.bool,
|
last: PropTypes.bool,
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p: PropTypes.shape({
|
||||||
|
state: PropTypes.shape({
|
||||||
|
isEnabled: PropTypes.bool.isRequired,
|
||||||
|
upgradeDeadline: PropTypes.string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
Day.defaultProps = {
|
Day.defaultProps = {
|
||||||
first: false,
|
first: false,
|
||||||
last: false,
|
last: false,
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p: {
|
||||||
|
state: {
|
||||||
|
isEnabled: false,
|
||||||
|
upgradeDeadline: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Day);
|
export default injectIntl(Day);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
/** [MM-P2P] Experiment (import) */
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
@@ -6,7 +8,8 @@ import { useModel } from '../../../generic/model-store';
|
|||||||
import Day from './Day';
|
import Day from './Day';
|
||||||
import { daycmp, isLearnerAssignment } from '../utils';
|
import { daycmp, isLearnerAssignment } from '../utils';
|
||||||
|
|
||||||
const Timeline = () => {
|
/** [MM-P2P] Experiment (argument) */
|
||||||
|
export default function Timeline({ mmp2p }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -63,10 +66,17 @@ const Timeline = () => {
|
|||||||
return (
|
return (
|
||||||
<ul className="list-unstyled m-0 mt-4 pt-2">
|
<ul className="list-unstyled m-0 mt-4 pt-2">
|
||||||
{groupedDates.map((groupedDate) => (
|
{groupedDates.map((groupedDate) => (
|
||||||
<Day key={groupedDate.date} {...groupedDate} />
|
<Day key={groupedDate.date} {...groupedDate} mmp2p={mmp2p} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
Timeline.propTypes = {
|
||||||
|
mmp2p: PropTypes.shape({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Timeline;
|
Timeline.defaultProps = {
|
||||||
|
mmp2p: {},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { generatePath, useHistory } from 'react-router';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
|
|
||||||
|
|
||||||
const DiscussionTab = () => {
|
|
||||||
const { courseId } = useSelector(state => state.courseHome);
|
|
||||||
const { path } = useParams();
|
|
||||||
const [originalPath] = useState(path);
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const [, iFrameHeight] = useIFrameHeight();
|
|
||||||
useIFramePluginEvents({
|
|
||||||
'discussions.navigate': (payload) => {
|
|
||||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
|
||||||
history.push(`${basePath}/${payload.path}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
src={discussionsUrl}
|
|
||||||
className="d-flex w-100 border-0"
|
|
||||||
height={iFrameHeight}
|
|
||||||
style={{ minHeight: '60rem' }}
|
|
||||||
title="discussion"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
DiscussionTab.propTypes = {};
|
|
||||||
|
|
||||||
export default injectIntl(DiscussionTab);
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { getConfig, history } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
||||||
import { AppProvider } from '@edx/frontend-platform/react';
|
|
||||||
import { render } from '@testing-library/react';
|
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
|
||||||
import React from 'react';
|
|
||||||
import { Route } from 'react-router';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
|
||||||
import {
|
|
||||||
initializeMockApp, messageEvent, screen, waitFor,
|
|
||||||
} from '../../setupTest';
|
|
||||||
import initializeStore from '../../store';
|
|
||||||
import { TabContainer } from '../../tab-page';
|
|
||||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
|
||||||
import { fetchDiscussionTab } from '../data/thunks';
|
|
||||||
import DiscussionTab from './DiscussionTab';
|
|
||||||
|
|
||||||
initializeMockApp();
|
|
||||||
jest.mock('@edx/frontend-platform/analytics');
|
|
||||||
|
|
||||||
describe('DiscussionTab', () => {
|
|
||||||
let axiosMock;
|
|
||||||
let store;
|
|
||||||
let component;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
store = initializeStore();
|
|
||||||
component = (
|
|
||||||
<AppProvider store={store}>
|
|
||||||
<UserMessagesProvider>
|
|
||||||
<Route path="/course/:courseId/discussion">
|
|
||||||
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
|
|
||||||
<DiscussionTab />
|
|
||||||
</TabContainer>
|
|
||||||
</Route>
|
|
||||||
</UserMessagesProvider>
|
|
||||||
</AppProvider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const courseMetadata = Factory.build('courseHomeMetadata', { user_timezone: 'America/New_York' });
|
|
||||||
const { id: courseId } = courseMetadata;
|
|
||||||
|
|
||||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
|
||||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
|
||||||
history.push(`/course/${courseId}/discussion`); // so tab can pull course id from url
|
|
||||||
|
|
||||||
render(component);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resizes when it gets a size hint from iframe', async () => {
|
|
||||||
window.postMessage({ ...messageEvent, payload: { height: 1234 } }, '*');
|
|
||||||
await waitFor(() => expect(screen.getByTitle('discussion'))
|
|
||||||
.toHaveAttribute('height', String(1234)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,7 @@ import { unsubscribeFromCourseGoal } from '../data/api';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import ResultPage from './ResultPage';
|
import ResultPage from './ResultPage';
|
||||||
|
|
||||||
const GoalUnsubscribe = ({ intl }) => {
|
function GoalUnsubscribe({ intl }) {
|
||||||
const { token } = useParams();
|
const { token } = useParams();
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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.
|
// 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
|
// 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 });
|
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []); // deps=[] to only run once
|
}, []); // deps=[] to only run once
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +48,7 @@ const GoalUnsubscribe = ({ intl }) => {
|
|||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
GoalUnsubscribe.propTypes = {
|
GoalUnsubscribe.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button, Hyperlink } from '@edx/paragon';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
|
import { ReactComponent as UnsubscribeIcon } from './unsubscribe.svg';
|
||||||
|
|
||||||
const ResultPage = ({ courseTitle, error, intl }) => {
|
function ResultPage({ courseTitle, error, intl }) {
|
||||||
const errorDescription = (
|
const errorDescription = (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="learning.goals.unsubscribe.errorDescription"
|
id="learning.goals.unsubscribe.errorDescription"
|
||||||
@@ -44,7 +44,7 @@ const ResultPage = ({ courseTitle, error, intl }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
ResultPage.defaultProps = {
|
ResultPage.defaultProps = {
|
||||||
courseTitle: null,
|
courseTitle: null,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
const LiveTab = () => {
|
|
||||||
const { courseId } = useSelector(state => state.courseHome);
|
|
||||||
const liveModel = useSelector(state => state.models.live);
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = document.getElementById('lti-tab-embed');
|
|
||||||
if (iframe) {
|
|
||||||
iframe.className += ' vh-100 w-100 border-0';
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="live_tab"
|
|
||||||
// eslint-disable-next-line react/no-danger
|
|
||||||
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LiveTab;
|
|
||||||
@@ -9,10 +9,12 @@ import { useModel } from '../../generic/model-store';
|
|||||||
import { isLearnerAssignment } from '../dates-tab/utils';
|
import { isLearnerAssignment } from '../dates-tab/utils';
|
||||||
import './DateSummary.scss';
|
import './DateSummary.scss';
|
||||||
|
|
||||||
const DateSummary = ({
|
export default function DateSummary({
|
||||||
dateBlock,
|
dateBlock,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
}) => {
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p,
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -23,6 +25,9 @@ const DateSummary = ({
|
|||||||
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||||
|
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
const showMMP2P = mmp2p.state.isEnabled && (dateBlock.dateType === 'verified-upgrade-deadline');
|
||||||
|
|
||||||
const logVerifiedUpgradeClick = () => {
|
const logVerifiedUpgradeClick = () => {
|
||||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||||
org_key: org,
|
org_key: org,
|
||||||
@@ -40,7 +45,8 @@ const DateSummary = ({
|
|||||||
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
||||||
<div className="ml-1 font-weight-bold">
|
<div className="ml-1 font-weight-bold">
|
||||||
<FormattedDate
|
<FormattedDate
|
||||||
value={dateBlock.date}
|
/** [MM-P2P] Experiment */
|
||||||
|
value={showMMP2P ? mmp2p.state.upgradeDeadline : dateBlock.date}
|
||||||
day="numeric"
|
day="numeric"
|
||||||
month="short"
|
month="short"
|
||||||
weekday="short"
|
weekday="short"
|
||||||
@@ -49,33 +55,48 @@ const DateSummary = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row ml-4 pr-2">
|
{/** [MM-P2P] Experiment (conditional) */}
|
||||||
<div className="date-summary-text">
|
{ showMMP2P ? (
|
||||||
{linkedTitle && (
|
<div className="row ml-4 pr-2">
|
||||||
|
<div className="date-summary-text">
|
||||||
<div className="font-weight-bold mt-2">
|
<div className="font-weight-bold mt-2">
|
||||||
<a href={dateBlock.link}>{dateBlock.title}</a>
|
Last chance to upgrade
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="date-summary-text mt-1">
|
||||||
|
You are still eligible to upgrade to a Verified Certificate!
|
||||||
|
Unlock full course access and highlight the knowledge you'll gain.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row ml-4 pr-2">
|
||||||
|
<div className="date-summary-text">
|
||||||
|
{linkedTitle && (
|
||||||
|
<div className="font-weight-bold mt-2">
|
||||||
|
<a href={dateBlock.link}>{dateBlock.title}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!linkedTitle && (
|
||||||
|
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dateBlock.description && (
|
||||||
|
<div className="date-summary-text mt-1">{dateBlock.description}</div>
|
||||||
)}
|
)}
|
||||||
{!linkedTitle && (
|
{!linkedTitle && dateBlock.link && (
|
||||||
<div className="font-weight-bold mt-2">{dateBlock.title}</div>
|
<a
|
||||||
|
href={dateBlock.link}
|
||||||
|
onClick={dateBlock.dateType === 'verified-upgrade-deadline' ? logVerifiedUpgradeClick : () => {}}
|
||||||
|
className="description-link"
|
||||||
|
>
|
||||||
|
{dateBlock.linkText}
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
DateSummary.propTypes = {
|
DateSummary.propTypes = {
|
||||||
dateBlock: PropTypes.shape({
|
dateBlock: PropTypes.shape({
|
||||||
@@ -88,10 +109,22 @@ DateSummary.propTypes = {
|
|||||||
learnerHasAccess: PropTypes.bool,
|
learnerHasAccess: PropTypes.bool,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
userTimezone: PropTypes.string,
|
userTimezone: PropTypes.string,
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p: PropTypes.shape({
|
||||||
|
state: PropTypes.shape({
|
||||||
|
isEnabled: PropTypes.bool.isRequired,
|
||||||
|
upgradeDeadline: PropTypes.string,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
DateSummary.defaultProps = {
|
DateSummary.defaultProps = {
|
||||||
userTimezone: null,
|
userTimezone: null,
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p: {
|
||||||
|
state: {
|
||||||
|
isEnabled: false,
|
||||||
|
upgradeDeadline: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DateSummary;
|
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
const LmsHtmlFragment = ({
|
export default function LmsHtmlFragment({
|
||||||
className,
|
className,
|
||||||
html,
|
html,
|
||||||
title,
|
title,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) {
|
||||||
const wholePage = `
|
const wholePage = `
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||||
<link rel="stylesheet" href="/static/${getConfig().LEGACY_THEME_NAME ? `${getConfig().LEGACY_THEME_NAME}/` : ''}css/bootstrap/lms-main.css">
|
<link rel="stylesheet" href="/static/${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>
|
</head>
|
||||||
<body class="${className}">${html}</body>
|
<body class="${className}">${html}</body>
|
||||||
<script>
|
<script>
|
||||||
@@ -55,7 +55,7 @@ const LmsHtmlFragment = ({
|
|||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
LmsHtmlFragment.defaultProps = {
|
LmsHtmlFragment.defaultProps = {
|
||||||
className: '',
|
className: '',
|
||||||
@@ -66,5 +66,3 @@ LmsHtmlFragment.propTypes = {
|
|||||||
html: PropTypes.string.isRequired,
|
html: PropTypes.string.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LmsHtmlFragment;
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
import { AlertList } from '../../generic/user-messages';
|
import { AlertList } from '../../generic/user-messages';
|
||||||
|
|
||||||
@@ -29,7 +28,10 @@ import WelcomeMessage from './widgets/WelcomeMessage';
|
|||||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
||||||
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
|
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
|
||||||
|
|
||||||
const OutlineTab = ({ intl }) => {
|
/** [MM-P2P] Experiment */
|
||||||
|
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
|
||||||
|
|
||||||
|
function OutlineTab({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
proctoringPanelStatus,
|
proctoringPanelStatus,
|
||||||
@@ -101,26 +103,12 @@ const OutlineTab = ({ intl }) => {
|
|||||||
return userRoleNames.includes('enterprise_learner');
|
return userRoleNames.includes('enterprise_learner');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** [[MM-P2P] Experiment */
|
||||||
|
const MMP2P = initHomeMMP2P(courseId);
|
||||||
|
|
||||||
/** show post enrolment survey to only B2C learners */
|
/** show post enrolment survey to only B2C learners */
|
||||||
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
|
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentParams = new URLSearchParams(location.search);
|
|
||||||
const startCourse = currentParams.get('start_course');
|
|
||||||
if (startCourse === '1') {
|
|
||||||
sendTrackEvent('enrollment.email.clicked.startcourse', {});
|
|
||||||
|
|
||||||
// Deleting the course_start query param as it only needs to be set once
|
|
||||||
// whenever passed in query params.
|
|
||||||
currentParams.delete('start_course');
|
|
||||||
history.replace({
|
|
||||||
search: currentParams.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [location.search]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
|
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
|
||||||
@@ -128,6 +116,7 @@ const OutlineTab = ({ intl }) => {
|
|||||||
<div role="heading" aria-level="1" className="h2">{title}</div>
|
<div role="heading" aria-level="1" className="h2">{title}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
|
||||||
<div className="row course-outline-tab">
|
<div className="row course-outline-tab">
|
||||||
<AccountActivationAlert />
|
<AccountActivationAlert />
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
@@ -139,17 +128,21 @@ const OutlineTab = ({ intl }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col col-12 col-md-8">
|
<div className="col col-12 col-md-8">
|
||||||
<AlertList
|
{ /** [MM-P2P] Experiment (the conditional) */ }
|
||||||
topic="outline-course-alerts"
|
{ !MMP2P.state.isEnabled
|
||||||
className="mb-3"
|
&& (
|
||||||
customAlerts={{
|
<AlertList
|
||||||
...certificateAvailableAlert,
|
topic="outline-course-alerts"
|
||||||
...courseEndAlert,
|
className="mb-3"
|
||||||
...courseStartAlert,
|
customAlerts={{
|
||||||
...scheduledContentAlert,
|
...certificateAvailableAlert,
|
||||||
}}
|
...courseEndAlert,
|
||||||
/>
|
...courseStartAlert,
|
||||||
{isSelfPaced && hasDeadlines && (
|
...scheduledContentAlert,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
|
||||||
<>
|
<>
|
||||||
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
|
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
|
||||||
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
|
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
|
||||||
@@ -192,27 +185,35 @@ const OutlineTab = ({ intl }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CourseTools />
|
<CourseTools />
|
||||||
<UpgradeNotification
|
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||||
offer={offer}
|
{ MMP2P.state.isEnabled
|
||||||
verifiedMode={verifiedMode}
|
? <MMP2PFlyover isStatic options={MMP2P} />
|
||||||
accessExpiration={accessExpiration}
|
: (
|
||||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
<UpgradeNotification
|
||||||
marketingUrl={marketingUrl}
|
offer={offer}
|
||||||
upsellPageName="course_home"
|
verifiedMode={verifiedMode}
|
||||||
userTimezone={userTimezone}
|
accessExpiration={accessExpiration}
|
||||||
shouldDisplayBorder
|
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||||
timeOffsetMillis={timeOffsetMillis}
|
marketingUrl={marketingUrl}
|
||||||
courseId={courseId}
|
upsellPageName="course_home"
|
||||||
org={org}
|
userTimezone={userTimezone}
|
||||||
|
shouldDisplayBorder
|
||||||
|
timeOffsetMillis={timeOffsetMillis}
|
||||||
|
courseId={courseId}
|
||||||
|
org={org}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CourseDates
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p={MMP2P}
|
||||||
/>
|
/>
|
||||||
<CourseDates />
|
|
||||||
<CourseHandouts />
|
<CourseHandouts />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
OutlineTab.propTypes = {
|
OutlineTab.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* @jest-environment jsdom
|
* @jest-environment jsdom
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { Factory } from 'rosie';
|
import { Factory } from 'rosie';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||||
@@ -52,14 +51,9 @@ describe('Outline Tab', () => {
|
|||||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndRender(path = '') {
|
async function fetchAndRender() {
|
||||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||||
await act(async () => render(
|
await act(async () => render(<OutlineTab />, { store }));
|
||||||
<MemoryRouter initialEntries={[path]}>
|
|
||||||
<OutlineTab />
|
|
||||||
</MemoryRouter>,
|
|
||||||
{ store },
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -144,8 +138,25 @@ describe('Outline Tab', () => {
|
|||||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SequenceLink displays link', async () => {
|
it('SequenceLink displays points to legacy courseware', async () => {
|
||||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||||
|
setMetadata({
|
||||||
|
can_load_courseware: false,
|
||||||
|
});
|
||||||
|
setTabData({
|
||||||
|
course_blocks: { blocks: courseBlocks.blocks },
|
||||||
|
});
|
||||||
|
await fetchAndRender();
|
||||||
|
|
||||||
|
const sequenceLink = screen.getByText('Title of Sequence');
|
||||||
|
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SequenceLink displays points to courseware MFE', async () => {
|
||||||
|
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||||
|
setMetadata({
|
||||||
|
can_load_courseware: true,
|
||||||
|
});
|
||||||
setTabData({
|
setTabData({
|
||||||
course_blocks: { blocks: courseBlocks.blocks },
|
course_blocks: { blocks: courseBlocks.blocks },
|
||||||
});
|
});
|
||||||
@@ -344,26 +355,6 @@ describe('Outline Tab', () => {
|
|||||||
expect(spy).toHaveBeenCalledTimes(0);
|
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', () => {
|
describe('weekly learning goal is not set', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
setTabData({
|
setTabData({
|
||||||
@@ -383,25 +374,25 @@ describe('Outline Tab', () => {
|
|||||||
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
|
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each`
|
||||||
{ level: 'Casual', days: 1 },
|
level | days
|
||||||
{ level: 'Regular', days: 3 },
|
${'Casual'} | ${1}
|
||||||
{ level: 'Intense', days: 5 },
|
${'Regular'} | ${3}
|
||||||
])('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
|
${'Intense'} | ${5}
|
||||||
// click on Casual goal
|
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
|
||||||
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
|
// click on Casual goal
|
||||||
fireEvent.click(button);
|
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
|
||||||
// Verify the request was made
|
fireEvent.click(button);
|
||||||
await waitFor(() => {
|
// Verify the request was made
|
||||||
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
|
await waitFor(() => {
|
||||||
// subscribe is turned on automatically
|
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
|
||||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
|
// subscribe is turned on automatically
|
||||||
// verify that the additional info about subscriptions shows up
|
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
|
||||||
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
|
// verify that the additional info about subscriptions shows up
|
||||||
});
|
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
|
});
|
||||||
});
|
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
|
||||||
|
});
|
||||||
it('shows and hides subscribe to reminders additional text', async () => {
|
it('shows and hides subscribe to reminders additional text', async () => {
|
||||||
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
|
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
@@ -577,7 +568,7 @@ describe('Outline Tab', () => {
|
|||||||
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
|
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
|
||||||
expect(instructorToolbar).toBeInTheDocument();
|
expect(instructorToolbar).toBeInTheDocument();
|
||||||
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
||||||
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render banner when not masquerading', async () => {
|
it('does not render banner when not masquerading', async () => {
|
||||||
@@ -668,6 +659,7 @@ describe('Outline Tab', () => {
|
|||||||
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
||||||
cert_web_view_url: null,
|
cert_web_view_url: null,
|
||||||
certificate_available_date: tomorrow.toISOString(),
|
certificate_available_date: tomorrow.toISOString(),
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
date_blocks: [
|
date_blocks: [
|
||||||
@@ -695,6 +687,7 @@ describe('Outline Tab', () => {
|
|||||||
cert_data: {
|
cert_data: {
|
||||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||||
cert_web_view_url: null,
|
cert_web_view_url: null,
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
date_blocks: [
|
date_blocks: [
|
||||||
@@ -763,6 +756,7 @@ describe('Outline Tab', () => {
|
|||||||
cert_data: {
|
cert_data: {
|
||||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||||
cert_web_view_url: null,
|
cert_web_view_url: null,
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
date_blocks: [
|
date_blocks: [
|
||||||
@@ -789,16 +783,57 @@ describe('Outline Tab', () => {
|
|||||||
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
|
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
|
||||||
fireEvent.click(requestingButton);
|
fireEvent.click(requestingButton);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
||||||
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
|
|
||||||
{
|
{
|
||||||
courserun_key: courseId,
|
courserun_key: courseId,
|
||||||
is_staff: false,
|
is_staff: false,
|
||||||
org_key: 'edX',
|
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 () => {
|
it('tracks unverified cert button', async () => {
|
||||||
sendTrackEvent.mockClear();
|
sendTrackEvent.mockClear();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -809,6 +844,7 @@ describe('Outline Tab', () => {
|
|||||||
cert_data: {
|
cert_data: {
|
||||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||||
cert_web_view_url: null,
|
cert_web_view_url: null,
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
date_blocks: [
|
date_blocks: [
|
||||||
@@ -835,14 +871,12 @@ describe('Outline Tab', () => {
|
|||||||
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
|
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
|
||||||
fireEvent.click(requestingButton);
|
fireEvent.click(requestingButton);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||||
expect(sendTrackEvent).toHaveBeenCalledWith(
|
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
|
||||||
'edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
|
|
||||||
{
|
{
|
||||||
courserun_key: courseId,
|
courserun_key: courseId,
|
||||||
is_staff: false,
|
is_staff: false,
|
||||||
org_key: 'edX',
|
org_key: 'edX',
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -898,6 +932,7 @@ describe('Outline Tab', () => {
|
|||||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||||
cert_web_view_url: 'certificate/testuuid',
|
cert_web_view_url: 'certificate/testuuid',
|
||||||
certificate_available_date: null,
|
certificate_available_date: null,
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
date_blocks: [
|
date_blocks: [
|
||||||
@@ -923,6 +958,7 @@ describe('Outline Tab', () => {
|
|||||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||||
cert_web_view_url: null,
|
cert_web_view_url: null,
|
||||||
certificate_available_date: null,
|
certificate_available_date: null,
|
||||||
|
download_url: null,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
date_blocks: [
|
date_blocks: [
|
||||||
@@ -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', () => {
|
describe('Proctoring Info Panel', () => {
|
||||||
const onboardingReleaseDate = new Date();
|
const onboardingReleaseDate = new Date();
|
||||||
onboardingReleaseDate.setDate(new Date().getDate() - 7);
|
onboardingReleaseDate.setDate(new Date().getDate() - 7);
|
||||||
@@ -1021,22 +1084,6 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays expiration warning', async () => {
|
it('displays expiration warning', async () => {
|
||||||
const expirationDate = new Date();
|
|
||||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
|
||||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
|
||||||
onboarding_status: 'verified',
|
|
||||||
onboarding_link: 'test',
|
|
||||||
expiration_date: expirationDate.toString(),
|
|
||||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays expiration warning for other course', async () => {
|
|
||||||
const expirationDate = new Date();
|
const expirationDate = new Date();
|
||||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||||
@@ -1048,23 +1095,7 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
await screen.findByText('This course contains proctored exams');
|
await screen.findByText('This course contains proctored exams');
|
||||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays expired', async () => {
|
|
||||||
const expirationDate = new Date();
|
|
||||||
// This message appears after expiration, set the date 10 days in the past
|
|
||||||
expirationDate.setTime(expirationDate.getTime() - 864800000);
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
|
||||||
onboarding_status: 'verified',
|
|
||||||
onboarding_link: 'test',
|
|
||||||
expiration_date: expirationDate.toString(),
|
|
||||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByText('Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import { useModel } from '../../generic/model-store';
|
|||||||
import genericMessages from '../../generic/messages';
|
import genericMessages from '../../generic/messages';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const Section = ({
|
function Section({
|
||||||
courseId,
|
courseId,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
expand,
|
expand,
|
||||||
intl,
|
intl,
|
||||||
section,
|
section,
|
||||||
}) => {
|
}) {
|
||||||
const {
|
const {
|
||||||
complete,
|
complete,
|
||||||
sequenceIds,
|
sequenceIds,
|
||||||
@@ -38,7 +38,6 @@ const Section = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(defaultOpen);
|
setOpen(defaultOpen);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sectionTitle = (
|
const sectionTitle = (
|
||||||
@@ -110,7 +109,7 @@ const Section = ({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
Section.propTypes = {
|
Section.propTypes = {
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Hyperlink } from '@edx/paragon';
|
||||||
import {
|
import {
|
||||||
FormattedMessage,
|
FormattedMessage,
|
||||||
FormattedTime,
|
FormattedTime,
|
||||||
@@ -16,73 +17,38 @@ import EffortEstimate from '../../shared/effort-estimate';
|
|||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const SequenceLink = ({
|
function SequenceLink({
|
||||||
id,
|
id,
|
||||||
intl,
|
intl,
|
||||||
courseId,
|
courseId,
|
||||||
first,
|
first,
|
||||||
sequence,
|
sequence,
|
||||||
}) => {
|
}) {
|
||||||
const {
|
const {
|
||||||
complete,
|
complete,
|
||||||
description,
|
description,
|
||||||
due,
|
due,
|
||||||
|
legacyWebUrl,
|
||||||
showLink,
|
showLink,
|
||||||
title,
|
title,
|
||||||
} = sequence;
|
} = sequence;
|
||||||
const {
|
const {
|
||||||
userTimezone,
|
userTimezone,
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
|
const {
|
||||||
|
canLoadCourseware,
|
||||||
|
} = useModel('courseHomeMeta', courseId);
|
||||||
|
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||||
|
|
||||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
|
||||||
|
const coursewareUrl = (
|
||||||
|
canLoadCourseware
|
||||||
|
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
|
||||||
|
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
|
||||||
|
);
|
||||||
const displayTitle = showLink ? coursewareUrl : title;
|
const displayTitle = showLink ? coursewareUrl : title;
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
<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} />
|
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row w-100 m-0 ml-3 pl-3">
|
{due && (
|
||||||
<small className="text-body pl-2">
|
<div className="row w-100 m-0 ml-3 pl-3">
|
||||||
{due ? dueDateMessage : noDueDateMessage}
|
<small className="text-body pl-2">
|
||||||
</small>
|
<FormattedMessage
|
||||||
</div>
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
SequenceLink.propTypes = {
|
SequenceLink.propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const CERT_STATUS_TYPE = {
|
|||||||
UNVERIFIED: 'unverified',
|
UNVERIFIED: 'unverified',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CertificateStatusAlert = ({ intl, payload }) => {
|
function CertificateStatusAlert({ intl, payload }) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const {
|
const {
|
||||||
certificateAvailableDate,
|
certificateAvailableDate,
|
||||||
@@ -33,6 +33,7 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
|||||||
courseEndDate,
|
courseEndDate,
|
||||||
courseId,
|
courseId,
|
||||||
certURL,
|
certURL,
|
||||||
|
isWebCert,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
org,
|
org,
|
||||||
notPassingCourseEnded,
|
notPassingCourseEnded,
|
||||||
@@ -78,7 +79,11 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
|||||||
);
|
);
|
||||||
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
if (isWebCert) {
|
||||||
|
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||||
|
} else {
|
||||||
|
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||||
|
}
|
||||||
alertProps.buttonVisible = true;
|
alertProps.buttonVisible = true;
|
||||||
alertProps.buttonLink = certURL;
|
alertProps.buttonLink = certURL;
|
||||||
alertProps.buttonAction = () => {
|
alertProps.buttonAction = () => {
|
||||||
@@ -189,7 +194,7 @@ const CertificateStatusAlert = ({ intl, payload }) => {
|
|||||||
)}
|
)}
|
||||||
</AlertWrapper>
|
</AlertWrapper>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CertificateStatusAlert.propTypes = {
|
CertificateStatusAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
@@ -199,6 +204,7 @@ CertificateStatusAlert.propTypes = {
|
|||||||
courseEndDate: PropTypes.string,
|
courseEndDate: PropTypes.string,
|
||||||
courseId: PropTypes.string,
|
courseId: PropTypes.string,
|
||||||
certURL: PropTypes.string,
|
certURL: PropTypes.string,
|
||||||
|
isWebCert: PropTypes.bool,
|
||||||
userTimezone: PropTypes.string,
|
userTimezone: PropTypes.string,
|
||||||
org: PropTypes.string,
|
org: PropTypes.string,
|
||||||
notPassingCourseEnded: PropTypes.bool,
|
notPassingCourseEnded: PropTypes.bool,
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ function useCertificateStatusAlert(courseId) {
|
|||||||
certStatus,
|
certStatus,
|
||||||
certWebViewUrl,
|
certWebViewUrl,
|
||||||
certificateAvailableDate,
|
certificateAvailableDate,
|
||||||
|
downloadUrl,
|
||||||
} = certData || {};
|
} = certData || {};
|
||||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||||
|
const isWebCert = downloadUrl === null;
|
||||||
const isVerifiedEnrollmentMode = (
|
const isVerifiedEnrollmentMode = (
|
||||||
enrollmentMode !== null
|
enrollmentMode !== null
|
||||||
&& enrollmentMode !== undefined
|
&& enrollmentMode !== undefined
|
||||||
@@ -61,6 +63,9 @@ function useCertificateStatusAlert(courseId) {
|
|||||||
let certURL = '';
|
let certURL = '';
|
||||||
if (certWebViewUrl) {
|
if (certWebViewUrl) {
|
||||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||||
|
} else if (downloadUrl) {
|
||||||
|
// PDF Certificate
|
||||||
|
certURL = downloadUrl;
|
||||||
}
|
}
|
||||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||||
|
|
||||||
@@ -75,22 +80,22 @@ function useCertificateStatusAlert(courseId) {
|
|||||||
&& hasEnded
|
&& hasEnded
|
||||||
&& !userHasPassingGrade
|
&& !userHasPassingGrade
|
||||||
);
|
);
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
certificateAvailableDate,
|
certificateAvailableDate,
|
||||||
certURL,
|
certURL,
|
||||||
certStatus,
|
certStatus,
|
||||||
courseId,
|
courseId,
|
||||||
courseEndDate: endBlock && endBlock.date,
|
courseEndDate: endBlock && endBlock.date,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
|
isWebCert,
|
||||||
org,
|
org,
|
||||||
notPassingCourseEnded,
|
notPassingCourseEnded,
|
||||||
tabs,
|
tabs,
|
||||||
}), [certStatus, certURL, certificateAvailableDate, courseId,
|
};
|
||||||
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);
|
|
||||||
|
|
||||||
useAlert(isVisible || notPassingCourseEnded, {
|
useAlert(isVisible || notPassingCourseEnded, {
|
||||||
code: 'clientCertificateStatusAlert',
|
code: 'clientCertificateStatusAlert',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'outline-course-alerts',
|
topic: 'outline-course-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ import PropTypes from 'prop-types';
|
|||||||
import {
|
import {
|
||||||
FormattedDate,
|
FormattedDate,
|
||||||
FormattedMessage,
|
FormattedMessage,
|
||||||
FormattedRelativeTime,
|
FormattedRelative,
|
||||||
FormattedTime,
|
FormattedTime,
|
||||||
} from '@edx/frontend-platform/i18n';
|
} from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert } from '@edx/paragon';
|
||||||
import { Info } from '@edx/paragon/icons';
|
import { Info } from '@edx/paragon/icons';
|
||||||
|
|
||||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
||||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
|
||||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
|
||||||
|
|
||||||
const CourseEndAlert = ({ payload }) => {
|
function CourseEndAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
description,
|
description,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -22,19 +20,16 @@ const CourseEndAlert = ({ payload }) => {
|
|||||||
|
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||||
|
|
||||||
let msg;
|
|
||||||
const delta = new Date(endDate) - new Date();
|
|
||||||
const timeRemaining = (
|
const timeRemaining = (
|
||||||
<FormattedRelativeTime
|
<FormattedRelative
|
||||||
key="timeRemaining"
|
key="timeRemaining"
|
||||||
value={delta / 1000}
|
value={endDate}
|
||||||
numeric="auto"
|
|
||||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
|
||||||
updateIntervalInSeconds={YEAR_SEC}
|
|
||||||
{...timezoneFormatArgs}
|
{...timezoneFormatArgs}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let msg;
|
||||||
|
const delta = new Date(endDate) - new Date();
|
||||||
if (delta < DAY_MS) {
|
if (delta < DAY_MS) {
|
||||||
const courseEndTime = (
|
const courseEndTime = (
|
||||||
<FormattedTime
|
<FormattedTime
|
||||||
@@ -88,7 +83,7 @@ const CourseEndAlert = ({ payload }) => {
|
|||||||
{description}
|
{description}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseEndAlert.propTypes = {
|
CourseEndAlert.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ export function useCourseEndAlert(courseId) {
|
|||||||
const endDate = endBlock ? new Date(endBlock.date) : null;
|
const endDate = endBlock ? new Date(endBlock.date) : null;
|
||||||
const delta = endBlock ? endDate - new Date() : 0;
|
const delta = endBlock ? endDate - new Date() : 0;
|
||||||
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
description: endBlock && endBlock.description,
|
description: endBlock && endBlock.description,
|
||||||
endDate: endBlock && endBlock.date,
|
endDate: endBlock && endBlock.date,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
}), [endBlock, userTimezone]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientCourseEndAlert',
|
code: 'clientCourseEndAlert',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'outline-course-alerts',
|
topic: 'outline-course-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import outlineMessages from '../../messages';
|
|||||||
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
|
import useEnrollClickHandler from '../../../../alerts/enrollment-alert/clickHook';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
const PrivateCourseAlert = ({ intl, payload }) => {
|
function PrivateCourseAlert({ intl, payload }) {
|
||||||
const {
|
const {
|
||||||
anonymousUser,
|
anonymousUser,
|
||||||
canEnroll,
|
canEnroll,
|
||||||
@@ -100,7 +100,7 @@ const PrivateCourseAlert = ({ intl, payload }) => {
|
|||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
PrivateCourseAlert.propTypes = {
|
PrivateCourseAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ export function usePrivateCourseAlert(courseId) {
|
|||||||
* 2. the user is authenticated.
|
* 2. the user is authenticated.
|
||||||
* */
|
* */
|
||||||
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
|
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
anonymousUser: authenticatedUser === null,
|
anonymousUser: authenticatedUser === null,
|
||||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||||
courseId,
|
courseId,
|
||||||
}), [authenticatedUser, courseId, outline]);
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientPrivateCourseAlert',
|
code: 'clientPrivateCourseAlert',
|
||||||
dismissible: false,
|
dismissible: false,
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'outline-private-alerts',
|
topic: 'outline-private-alerts',
|
||||||
type: ALERT_TYPES.WELCOME,
|
type: ALERT_TYPES.WELCOME,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Alert, Button } from '@edx/paragon';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
const ScheduledContentAlert = ({ payload }) => {
|
function ScheduledContentAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
datesTabLink,
|
datesTabLink,
|
||||||
} = payload;
|
} = payload;
|
||||||
@@ -38,7 +38,7 @@ const ScheduledContentAlert = ({ payload }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
ScheduledContentAlert.propTypes = {
|
ScheduledContentAlert.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ const useScheduledContentAlert = (courseId) => {
|
|||||||
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
|
&& !!Object.values(courses).find(course => course.hasScheduledContent === true)
|
||||||
);
|
);
|
||||||
const { isEnrolled } = useModel('courseHomeMeta', courseId);
|
const { isEnrolled } = useModel('courseHomeMeta', courseId);
|
||||||
const payload = useMemo(() => ({
|
const payload = {
|
||||||
datesTabLink,
|
datesTabLink,
|
||||||
}), [datesTabLink]);
|
};
|
||||||
useAlert(hasScheduledContent && isEnrolled, {
|
useAlert(hasScheduledContent && isEnrolled, {
|
||||||
code: 'ScheduledContentAlert',
|
code: 'ScheduledContentAlert',
|
||||||
payload,
|
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||||
topic: 'outline-course-alerts',
|
topic: 'outline-course-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -231,11 +231,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Expiring Soon',
|
defaultMessage: 'Expiring Soon',
|
||||||
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
|
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
|
||||||
},
|
},
|
||||||
expiredProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.expired',
|
|
||||||
defaultMessage: 'Expired',
|
|
||||||
description: 'A label to indicate that proctortrack onboarding exam has expired',
|
|
||||||
},
|
|
||||||
proctoringCurrentStatus: {
|
proctoringCurrentStatus: {
|
||||||
id: 'learning.proctoringPanel.status',
|
id: 'learning.proctoringPanel.status',
|
||||||
defaultMessage: 'Current Onboarding Status:',
|
defaultMessage: 'Current Onboarding Status:',
|
||||||
@@ -283,14 +278,9 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
expiringSoonProctoringMessage: {
|
expiringSoonProctoringMessage: {
|
||||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||||
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
|
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
|
||||||
},
|
},
|
||||||
expiredProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.expired',
|
|
||||||
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
|
|
||||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
|
|
||||||
},
|
|
||||||
proctoringPanelGeneralInfo: {
|
proctoringPanelGeneralInfo: {
|
||||||
id: 'learning.proctoringPanel.generalInfo',
|
id: 'learning.proctoringPanel.generalInfo',
|
||||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
@@ -7,9 +8,11 @@ import DateSummary from '../DateSummary';
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
const CourseDates = ({
|
function CourseDates({
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p,
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -37,6 +40,8 @@ const CourseDates = ({
|
|||||||
key={courseDateBlock.title + courseDateBlock.date}
|
key={courseDateBlock.title + courseDateBlock.date}
|
||||||
dateBlock={courseDateBlock}
|
dateBlock={courseDateBlock}
|
||||||
userTimezone={userTimezone}
|
userTimezone={userTimezone}
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p={mmp2p}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -46,10 +51,17 @@ const CourseDates = ({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseDates.propTypes = {
|
CourseDates.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p: PropTypes.shape({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
CourseDates.defaultProps = {
|
||||||
|
/** [MM-P2P] Experiment */
|
||||||
|
mmp2p: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CourseDates);
|
export default injectIntl(CourseDates);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import LmsHtmlFragment from '../LmsHtmlFragment';
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
const CourseHandouts = ({ intl }) => {
|
function CourseHandouts({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -29,7 +29,7 @@ const CourseHandouts = ({ intl }) => {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseHandouts.propTypes = {
|
CourseHandouts.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import messages from '../messages';
|
|||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
|
import LaunchCourseHomeTourButton from '../../../product-tours/newUserCourseHomeTour/LaunchCourseHomeTourButton';
|
||||||
|
|
||||||
const CourseTools = ({ intl }) => {
|
function CourseTools({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -79,7 +79,7 @@ const CourseTools = ({ intl }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseTools.propTypes = {
|
CourseTools.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -2,35 +2,35 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
|
||||||
const FlagButton = ({
|
function FlagButton({
|
||||||
buttonIcon,
|
buttonIcon,
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
isSelected,
|
isSelected,
|
||||||
}) => (
|
}) {
|
||||||
<button
|
return (
|
||||||
type="button"
|
<button
|
||||||
className={classnames(
|
type="button"
|
||||||
'flag-button row w-100 align-content-between m-1.5 py-3.5',
|
className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5',
|
||||||
isSelected ? 'flag-button-selected' : '',
|
isSelected ? 'flag-button-selected' : '')}
|
||||||
)}
|
aria-checked={isSelected}
|
||||||
aria-checked={isSelected}
|
role="radio"
|
||||||
role="radio"
|
onClick={() => handleSelect()}
|
||||||
onClick={() => handleSelect()}
|
data-testid={`weekly-learning-goal-input-${title}`}
|
||||||
data-testid={`weekly-learning-goal-input-${title}`}
|
>
|
||||||
>
|
<div className="row w-100 m-0 justify-content-center pb-1">
|
||||||
<div className="row w-100 m-0 justify-content-center pb-1">
|
{buttonIcon}
|
||||||
{buttonIcon}
|
</div>
|
||||||
</div>
|
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
|
||||||
<div className={classnames('row w-100 m-0 justify-content-center small text-gray-700 pb-1', isSelected ? 'font-weight-bold' : '')}>
|
{title}
|
||||||
{title}
|
</div>
|
||||||
</div>
|
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
|
||||||
<div className={classnames('row w-100 m-0 justify-content-center micro text-gray-500', isSelected ? 'font-weight-bold' : '')}>
|
{text}
|
||||||
{text}
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
FlagButton.propTypes = {
|
FlagButton.propTypes = {
|
||||||
buttonIcon: PropTypes.element.isRequired,
|
buttonIcon: PropTypes.element.isRequired,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import { ReactComponent as FlagRegularIcon } from './flag_gray.svg';
|
|||||||
import FlagButton from './FlagButton';
|
import FlagButton from './FlagButton';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const LearningGoalButton = ({
|
function LearningGoalButton({
|
||||||
level,
|
level,
|
||||||
isSelected,
|
isSelected,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) {
|
||||||
const buttonDetails = {
|
const buttonDetails = {
|
||||||
casual: {
|
casual: {
|
||||||
daysPerWeek: 1,
|
daysPerWeek: 1,
|
||||||
@@ -47,7 +47,7 @@ const LearningGoalButton = ({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
LearningGoalButton.propTypes = {
|
LearningGoalButton.propTypes = {
|
||||||
level: PropTypes.string.isRequired,
|
level: PropTypes.string.isRequired,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getProctoringInfoData } from '../../data/api';
|
|||||||
import { fetchProctoringInfoResolved } from '../../data/slice';
|
import { fetchProctoringInfoResolved } from '../../data/slice';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
const ProctoringInfoPanel = ({ intl }) => {
|
function ProctoringInfoPanel({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -35,7 +35,6 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
error: 'error',
|
error: 'error',
|
||||||
otherCourseApproved: 'otherCourseApproved',
|
otherCourseApproved: 'otherCourseApproved',
|
||||||
expiringSoon: 'expiringSoon',
|
expiringSoon: 'expiringSoon',
|
||||||
expired: 'expired',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getReadableStatusClass(examStatus) {
|
function getReadableStatusClass(examStatus) {
|
||||||
@@ -55,14 +54,9 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
return readableClass;
|
return readableClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCurrentlySubmitted(examStatus) {
|
function isNotYetSubmitted(examStatus) {
|
||||||
const SUBMITTED_STATES = ['submitted', 'second_review_required'];
|
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
|
||||||
return SUBMITTED_STATES.includes(examStatus);
|
return !NO_SHOW_STATES.includes(examStatus);
|
||||||
}
|
|
||||||
|
|
||||||
function isSubmissionRequired(examStatus) {
|
|
||||||
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
|
|
||||||
return !OK_STATES.includes(examStatus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNotYetReleased(examReleaseDate) {
|
function isNotYetReleased(examReleaseDate) {
|
||||||
@@ -83,19 +77,11 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
return borderClass;
|
return borderClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExpired(dateString) {
|
|
||||||
// Returns true if the expiration date has passed
|
|
||||||
const today = new Date();
|
|
||||||
const expirationDateObject = new Date(dateString);
|
|
||||||
return today >= expirationDateObject.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpiringSoon(dateString) {
|
function isExpiringSoon(dateString) {
|
||||||
// Returns true if the expiration date is within 28 days
|
// Returns true if the expiration date is within 28 days
|
||||||
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const expirationDateObject = new Date(dateString);
|
const expirationDateObject = new Date(dateString);
|
||||||
return today > expirationDateObject.getTime() - twentyeightDays;
|
return today > expirationDateObject.getTime() - 2419200000;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,9 +96,7 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
setStatus(response.onboarding_status);
|
setStatus(response.onboarding_status);
|
||||||
setLink(response.onboarding_link);
|
setLink(response.onboarding_link);
|
||||||
const expirationDate = response.expiration_date;
|
const expirationDate = response.expiration_date;
|
||||||
if (expirationDate && isExpired(expirationDate)) {
|
if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||||
setReadableStatus(getReadableStatusClass('expired'));
|
|
||||||
} else if (expirationDate && isExpiringSoon(expirationDate)) {
|
|
||||||
setReadableStatus(getReadableStatusClass('expiringSoon'));
|
setReadableStatus(getReadableStatusClass('expiringSoon'));
|
||||||
} else {
|
} else {
|
||||||
setReadableStatus(getReadableStatusClass(response.onboarding_status));
|
setReadableStatus(getReadableStatusClass(response.onboarding_status));
|
||||||
@@ -128,7 +112,6 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
dispatch(fetchProctoringInfoResolved());
|
dispatch(fetchProctoringInfoResolved());
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
let onboardingExamButton = null;
|
let onboardingExamButton = null;
|
||||||
@@ -171,7 +154,6 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
|
||||||
<>
|
<>
|
||||||
{ showInfoPanel && (
|
{ showInfoPanel && (
|
||||||
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
|
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
|
||||||
@@ -193,17 +175,17 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
|
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{!isCurrentlySubmitted(status) && (
|
{isNotYetSubmitted(status) && (
|
||||||
intl.formatMessage(messages.proctoringPanelGeneralInfo)
|
intl.formatMessage(messages.proctoringPanelGeneralInfo)
|
||||||
)}
|
)}
|
||||||
{isCurrentlySubmitted(status) && (
|
{!isNotYetSubmitted(status) && (
|
||||||
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
|
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
|
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isSubmissionRequired(readableStatus) && (
|
{isNotYetSubmitted(status) && (
|
||||||
onboardingExamButton
|
onboardingExamButton
|
||||||
)}
|
)}
|
||||||
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
|
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
|
||||||
@@ -214,7 +196,7 @@ const ProctoringInfoPanel = ({ intl }) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
ProctoringInfoPanel.propTypes = {
|
ProctoringInfoPanel.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
const StartOrResumeCourseCard = ({ intl }) => {
|
function StartOrResumeCourseCard({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = 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 */}
|
{/* 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.Footer><></></Card.Footer>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
StartOrResumeCourseCard.propTypes = {
|
StartOrResumeCourseCard.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Form, Card, Icon } from '@edx/paragon';
|
import { Form, Card, Icon } from '@edx/paragon';
|
||||||
import { history } from '@edx/frontend-platform';
|
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
@@ -15,11 +13,11 @@ import { saveWeeklyLearningGoal } from '../../data';
|
|||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
import './FlagButton.scss';
|
import './FlagButton.scss';
|
||||||
|
|
||||||
const WeeklyLearningGoalCard = ({
|
function WeeklyLearningGoalCard({
|
||||||
daysPerWeek,
|
daysPerWeek,
|
||||||
subscribedToReminders,
|
subscribedToReminders,
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -34,9 +32,8 @@ const WeeklyLearningGoalCard = ({
|
|||||||
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
|
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
|
||||||
// eslint-disable-next-line react/prop-types
|
// eslint-disable-next-line react/prop-types
|
||||||
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
|
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const handleSelect = (days, triggeredFromEmail = false) => {
|
function handleSelect(days) {
|
||||||
// Set the subscription button if this is the first time selecting a goal
|
// Set the subscription button if this is the first time selecting a goal
|
||||||
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
|
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
|
||||||
setGetReminderSelected(selectReminders);
|
setGetReminderSelected(selectReminders);
|
||||||
@@ -50,11 +47,8 @@ const WeeklyLearningGoalCard = ({
|
|||||||
num_days: days,
|
num_days: days,
|
||||||
reminder_selected: selectReminders,
|
reminder_selected: selectReminders,
|
||||||
});
|
});
|
||||||
if (triggeredFromEmail) {
|
|
||||||
sendTrackEvent('enrollment.email.clicked.setgoal', {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
function handleSubscribeToReminders(event) {
|
function handleSubscribeToReminders(event) {
|
||||||
const isGetReminderChecked = event.target.checked;
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
id="courseHome-weeklyLearningGoal"
|
id="courseHome-weeklyLearningGoal"
|
||||||
@@ -147,7 +125,7 @@ const WeeklyLearningGoalCard = ({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
WeeklyLearningGoalCard.propTypes = {
|
WeeklyLearningGoalCard.propTypes = {
|
||||||
daysPerWeek: PropTypes.number,
|
daysPerWeek: PropTypes.number,
|
||||||
|
|||||||
@@ -11,22 +11,21 @@ import messages from '../messages';
|
|||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
import { dismissWelcomeMessage } from '../../data/thunks';
|
import { dismissWelcomeMessage } from '../../data/thunks';
|
||||||
|
|
||||||
const WelcomeMessage = ({ courseId, intl }) => {
|
function WelcomeMessage({ courseId, intl }) {
|
||||||
const {
|
const {
|
||||||
welcomeMessageHtml,
|
welcomeMessageHtml,
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
|
|
||||||
|
if (!welcomeMessageHtml) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [display, setDisplay] = useState(true);
|
const [display, setDisplay] = useState(true);
|
||||||
|
|
||||||
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
|
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
|
||||||
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
|
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
|
||||||
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
|
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
if (!welcomeMessageHtml) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
data-testid="alert-container-welcome"
|
data-testid="alert-container-welcome"
|
||||||
@@ -70,7 +69,7 @@ const WelcomeMessage = ({ courseId, intl }) => {
|
|||||||
</TransitionReplace>
|
</TransitionReplace>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
WelcomeMessage.propTypes = {
|
WelcomeMessage.propTypes = {
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useModel } from '../../generic/model-store';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const ProgressHeader = ({ intl }) => {
|
function ProgressHeader({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
targetUserId,
|
targetUserId,
|
||||||
@@ -26,16 +26,18 @@ const ProgressHeader = ({ intl }) => {
|
|||||||
: intl.formatMessage(messages.progressHeader);
|
: intl.formatMessage(messages.progressHeader);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
<>
|
||||||
<h1>{pageTitle}</h1>
|
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
|
||||||
{administrator && studioUrl && (
|
<h1>{pageTitle}</h1>
|
||||||
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
{administrator && studioUrl && (
|
||||||
{intl.formatMessage(messages.studioLink)}
|
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
|
||||||
</Button>
|
{intl.formatMessage(messages.studioLink)}
|
||||||
)}
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
ProgressHeader.propTypes = {
|
ProgressHeader.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import RelatedLinks from './related-links/RelatedLinks';
|
|||||||
|
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
|
|
||||||
const ProgressTab = () => {
|
function ProgressTab() {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -55,6 +55,6 @@ const ProgressTab = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ProgressTab;
|
export default ProgressTab;
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ describe('Progress Tab', () => {
|
|||||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||||
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
|
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
|
||||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||||
const now = new Date();
|
|
||||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
||||||
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
|
|
||||||
|
|
||||||
function setMetadata(attributes, options) {
|
function setMetadata(attributes, options) {
|
||||||
const courseMetadata = Factory.build('courseHomeMetadata', 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 () => {
|
it('Displays webview link', async () => {
|
||||||
setTabData({
|
setTabData({
|
||||||
certificate_data: {
|
certificate_data: {
|
||||||
@@ -1180,66 +1220,6 @@ describe('Progress Tab', () => {
|
|||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
|
|
||||||
setMetadata({
|
|
||||||
can_view_certificate: false,
|
|
||||||
is_enrolled: true,
|
|
||||||
});
|
|
||||||
setTabData({
|
|
||||||
end: tomorrow.toISOString(),
|
|
||||||
certificate_data: undefined,
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}.`)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
|
|
||||||
setMetadata({
|
|
||||||
can_view_certificate: false,
|
|
||||||
is_enrolled: true,
|
|
||||||
});
|
|
||||||
setTabData({
|
|
||||||
end: tomorrow.toISOString(),
|
|
||||||
user_has_passing_grade: true,
|
|
||||||
certificate_data: undefined,
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}.`)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows certificate_available_date if learner is passing', async () => {
|
|
||||||
setMetadata({
|
|
||||||
can_view_certificate: false,
|
|
||||||
is_enrolled: true,
|
|
||||||
});
|
|
||||||
setTabData({
|
|
||||||
end: tomorrow.toISOString(),
|
|
||||||
user_has_passing_grade: true,
|
|
||||||
certificate_data: {
|
|
||||||
cert_status: 'earned_but_not_available',
|
|
||||||
certificate_available_date: overmorrow.toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
expect(screen.getByText('Certificate status'));
|
|
||||||
expect(screen.getByText(
|
|
||||||
overmorrow.toLocaleDateString('en-us', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
{ exact: false },
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Credit Information', () => {
|
describe('Credit Information', () => {
|
||||||
@@ -1303,7 +1283,7 @@ describe('Progress Tab', () => {
|
|||||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||||
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
||||||
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('does not render banner when not masquerading', async () => {
|
it('does not render banner when not masquerading', async () => {
|
||||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||||
@@ -1316,7 +1296,7 @@ describe('Progress Tab', () => {
|
|||||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||||
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
|
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('1/1/2020', { exact: false })).not.toBeInTheDocument();
|
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1332,7 +1312,7 @@ describe('Progress Tab', () => {
|
|||||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||||
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
||||||
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
|
||||||
expect(screen.getByText('1/1/2999', { exact: false })).toBeInTheDocument();
|
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('does not render banner when not masquerading', async () => {
|
it('does not render banner when not masquerading', async () => {
|
||||||
setMetadata({
|
setMetadata({
|
||||||
@@ -1344,7 +1324,7 @@ describe('Progress Tab', () => {
|
|||||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||||
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
|
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('1/1/2999', { exact: false })).not.toBeInTheDocument();
|
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../
|
|||||||
import { requestCert } from '../../data/thunks';
|
import { requestCert } from '../../data/thunks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const CertificateStatus = ({ intl }) => {
|
function CertificateStatus({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -22,8 +22,6 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
const {
|
const {
|
||||||
isEnrolled,
|
isEnrolled,
|
||||||
org,
|
org,
|
||||||
canViewCertificate,
|
|
||||||
userTimezone,
|
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courseHomeMeta', courseId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -47,8 +45,6 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
hasScheduledContent,
|
hasScheduledContent,
|
||||||
isEnrolled,
|
isEnrolled,
|
||||||
userHasPassingGrade,
|
userHasPassingGrade,
|
||||||
null, // CourseExitPageIsActive
|
|
||||||
canViewCertificate,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventProperties = {
|
const eventProperties = {
|
||||||
@@ -61,11 +57,12 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
|
|
||||||
let certStatus;
|
let certStatus;
|
||||||
let certWebViewUrl;
|
let certWebViewUrl;
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
let downloadUrl;
|
||||||
|
|
||||||
if (certificateData) {
|
if (certificateData) {
|
||||||
certStatus = certificateData.certStatus;
|
certStatus = certificateData.certStatus;
|
||||||
certWebViewUrl = certificateData.certWebViewUrl;
|
certWebViewUrl = certificateData.certWebViewUrl;
|
||||||
|
downloadUrl = certificateData.downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
let certCase;
|
let certCase;
|
||||||
@@ -141,10 +138,15 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
values={{ dashboardLink, profileLink }}
|
values={{ dashboardLink, profileLink }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (certWebViewUrl) {
|
if (certWebViewUrl) {
|
||||||
certEventName = 'earned_viewable';
|
certEventName = 'earned_viewable';
|
||||||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||||
buttonText = intl.formatMessage(messages.viewableButton);
|
buttonText = intl.formatMessage(messages.viewableButton);
|
||||||
|
} else if (downloadUrl) {
|
||||||
|
certEventName = 'earned_downloadable';
|
||||||
|
buttonLocation = downloadUrl;
|
||||||
|
buttonText = intl.formatMessage(messages.downloadableButton);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -176,22 +178,10 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// This code shouldn't be hit but coding defensively since switch expects a default statement
|
||||||
default:
|
default:
|
||||||
// if user completes a course before certificates are available, treat it as notAvailable
|
certCase = null;
|
||||||
// regardless of passing or nonpassing status
|
certEventName = 'no_certificate_status';
|
||||||
if (!canViewCertificate) {
|
|
||||||
certCase = 'notAvailable';
|
|
||||||
endDate = intl.formatDate(end, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
...timezoneFormatArgs,
|
|
||||||
});
|
|
||||||
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
|
|
||||||
} else {
|
|
||||||
certCase = null;
|
|
||||||
certEventName = 'no_certificate_status';
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +196,6 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
grade_variant: gradeEventName,
|
grade_variant: gradeEventName,
|
||||||
certificate_status_variant: certEventName,
|
certificate_status_variant: certEventName,
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!certCase) {
|
if (!certCase) {
|
||||||
@@ -258,7 +247,7 @@ const CertificateStatus = ({ intl }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CertificateStatus.propTypes = {
|
CertificateStatus.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -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.',
|
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',
|
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: {
|
viewableButton: {
|
||||||
id: 'progress.certificateStatus.viewableButton',
|
id: 'progress.certificateStatus.viewableButton',
|
||||||
defaultMessage: 'View my certificate',
|
defaultMessage: 'View my certificate',
|
||||||
@@ -71,11 +76,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Certificate status',
|
defaultMessage: 'Certificate status',
|
||||||
description: 'Header text when the certifcate is not available',
|
description: 'Header text when the certifcate is not available',
|
||||||
},
|
},
|
||||||
notAvailableEndDateBody: {
|
|
||||||
id: 'progress.certificateBody.notAvailable.endDate',
|
|
||||||
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
|
|
||||||
description: 'Shown for learners who have finished a course before grades and certificates are available.',
|
|
||||||
},
|
|
||||||
upgradeHeader: {
|
upgradeHeader: {
|
||||||
id: 'progress.certificateStatus.upgradeHeader',
|
id: 'progress.certificateStatus.upgradeHeader',
|
||||||
defaultMessage: 'Earn a certificate',
|
defaultMessage: 'Earn a certificate',
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) => {
|
function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
|
||||||
const [showCompletePopover, setShowCompletePopover] = useState(false);
|
|
||||||
|
|
||||||
if (!completePercentage) {
|
if (!completePercentage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [showCompletePopover, setShowCompletePopover] = useState(false);
|
||||||
|
|
||||||
const completeSegmentOffset = (3.6 * completePercentage) / 8;
|
const completeSegmentOffset = (3.6 * completePercentage) / 8;
|
||||||
let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
|
let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) =>
|
|||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CompleteDonutSegment.propTypes = {
|
CompleteDonutSegment.propTypes = {
|
||||||
completePercentage: PropTypes.number.isRequired,
|
completePercentage: PropTypes.number.isRequired,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
getLocale, injectIntl, intlShape, isRtl,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
import CompleteDonutSegment from './CompleteDonutSegment';
|
import CompleteDonutSegment from './CompleteDonutSegment';
|
||||||
@@ -10,7 +8,7 @@ import IncompleteDonutSegment from './IncompleteDonutSegment';
|
|||||||
import LockedDonutSegment from './LockedDonutSegment';
|
import LockedDonutSegment from './LockedDonutSegment';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const CompletionDonutChart = ({ intl }) => {
|
function CompletionDonutChart({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -28,8 +26,6 @@ const CompletionDonutChart = ({ intl }) => {
|
|||||||
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
|
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
|
||||||
const incompletePercentage = 100 - completePercentage - lockedPercentage;
|
const incompletePercentage = 100 - completePercentage - lockedPercentage;
|
||||||
|
|
||||||
const isLocaleRtl = isRtl(getLocale());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
|
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
|
||||||
@@ -39,7 +35,7 @@ const CompletionDonutChart = ({ intl }) => {
|
|||||||
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
|
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
|
||||||
<g className="donut-chart-text">
|
<g className="donut-chart-text">
|
||||||
<text x="50%" y="50%" className="donut-chart-number">
|
<text x="50%" y="50%" className="donut-chart-number">
|
||||||
{completePercentage}{isLocaleRtl && '\u200f'}%
|
{completePercentage}%
|
||||||
</text>
|
</text>
|
||||||
<text x="50%" y="50%" className="donut-chart-label">
|
<text x="50%" y="50%" className="donut-chart-label">
|
||||||
{intl.formatMessage(messages.donutLabel)}
|
{intl.formatMessage(messages.donutLabel)}
|
||||||
@@ -60,7 +56,7 @@ const CompletionDonutChart = ({ intl }) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CompletionDonutChart.propTypes = {
|
CompletionDonutChart.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -4,21 +4,23 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|||||||
import CompletionDonutChart from './CompletionDonutChart';
|
import CompletionDonutChart from './CompletionDonutChart';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const CourseCompletion = ({ intl }) => (
|
function CourseCompletion({ intl }) {
|
||||||
<section className="text-dark-700 mb-4 rounded raised-card p-4">
|
return (
|
||||||
<div className="row w-100 m-0">
|
<section className="text-dark-700 mb-4 rounded raised-card p-4">
|
||||||
<div className="col-12 col-sm-6 col-md-7 p-0">
|
<div className="row w-100 m-0">
|
||||||
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
|
<div className="col-12 col-sm-6 col-md-7 p-0">
|
||||||
<p className="small">
|
<h2>{intl.formatMessage(messages.courseCompletion)}</h2>
|
||||||
{intl.formatMessage(messages.completionBody)}
|
<p className="small">
|
||||||
</p>
|
{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>
|
||||||
<div className="col-12 col-sm-6 col-md-5 mt-sm-n3 p-0 text-center">
|
</section>
|
||||||
<CompletionDonutChart />
|
);
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
CourseCompletion.propTypes = {
|
CourseCompletion.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
|
function IncompleteDonutSegment({ incompletePercentage, intl }) {
|
||||||
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
|
|
||||||
|
|
||||||
if (!incompletePercentage) {
|
if (!incompletePercentage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [showIncompletePopover, setShowIncompletePopover] = useState(false);
|
||||||
|
|
||||||
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
|
const incompleteSegmentOffset = (3.6 * incompletePercentage) / 16;
|
||||||
const incompleteTooltipDegree = incompletePercentage < 100 ? incompleteSegmentOffset : 0;
|
const incompleteTooltipDegree = incompletePercentage < 100 ? incompleteSegmentOffset : 0;
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ const IncompleteDonutSegment = ({ incompletePercentage, intl }) => {
|
|||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
IncompleteDonutSegment.propTypes = {
|
IncompleteDonutSegment.propTypes = {
|
||||||
incompletePercentage: PropTypes.number.isRequired,
|
incompletePercentage: PropTypes.number.isRequired,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const LockedDonutSegment = ({ intl, lockedPercentage }) => {
|
function LockedDonutSegment({ intl, lockedPercentage }) {
|
||||||
const [showLockedPopover, setShowLockedPopover] = useState(false);
|
const [showLockedPopover, setShowLockedPopover] = useState(false);
|
||||||
|
|
||||||
if (!lockedPercentage) {
|
if (!lockedPercentage) {
|
||||||
@@ -62,7 +62,7 @@ const LockedDonutSegment = ({ intl, lockedPercentage }) => {
|
|||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
LockedDonutSegment.propTypes = {
|
LockedDonutSegment.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DashboardLink } from '../../../shared/links';
|
|||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const CreditInformation = ({ intl }) => {
|
function CreditInformation({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -106,7 +106,7 @@ const CreditInformation = ({ intl }) => {
|
|||||||
{requirements}
|
{requirements}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CreditInformation.propTypes = {
|
CreditInformation.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import CreditInformation from '../../credit-information/CreditInformation';
|
|||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const CourseGrade = ({ intl }) => {
|
function CourseGrade({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -52,7 +52,7 @@ const CourseGrade = ({ intl }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseGrade.propTypes = {
|
CourseGrade.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useModel } from '../../../../generic/model-store';
|
|||||||
import GradeRangeTooltip from './GradeRangeTooltip';
|
import GradeRangeTooltip from './GradeRangeTooltip';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const CourseGradeFooter = ({ intl, passingGrade }) => {
|
function CourseGradeFooter({ intl, passingGrade }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -83,7 +83,7 @@ const CourseGradeFooter = ({ intl, passingGrade }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseGradeFooter.propTypes = {
|
CourseGradeFooter.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Button, Icon } from '@edx/paragon';
|
|||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const CourseGradeHeader = ({ intl }) => {
|
function CourseGradeHeader({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -81,7 +81,7 @@ const CourseGradeHeader = ({ intl }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CourseGradeHeader.propTypes = {
|
CourseGradeHeader.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useModel } from '../../../../generic/model-store';
|
|||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const CurrentGradeTooltip = ({ intl, tooltipClassName }) => {
|
function CurrentGradeTooltip({ intl, tooltipClassName }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -41,7 +41,7 @@ const CurrentGradeTooltip = ({ intl, tooltipClassName }) => {
|
|||||||
overlay={(
|
overlay={(
|
||||||
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
|
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
|
||||||
<Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}>
|
<Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}>
|
||||||
{currentGrade.toFixed(0)}{isLocaleRtl ? '\u200f' : ''}%
|
{currentGrade.toFixed(0)}%
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
@@ -62,7 +62,7 @@ const CurrentGradeTooltip = ({ intl, tooltipClassName }) => {
|
|||||||
</text>
|
</text>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CurrentGradeTooltip.defaultProps = {
|
CurrentGradeTooltip.defaultProps = {
|
||||||
tooltipClassName: '',
|
tooltipClassName: '',
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import React from 'react';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import {
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
getLocale, injectIntl, intlShape, isRtl,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
import CurrentGradeTooltip from './CurrentGradeTooltip';
|
import CurrentGradeTooltip from './CurrentGradeTooltip';
|
||||||
import PassingGradeTooltip from './PassingGradeTooltip';
|
import PassingGradeTooltip from './PassingGradeTooltip';
|
||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const GradeBar = ({ intl, passingGrade }) => {
|
function GradeBar({ intl, passingGrade }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -28,16 +26,14 @@ const GradeBar = ({ intl, passingGrade }) => {
|
|||||||
|
|
||||||
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||||
|
|
||||||
const adjustedRtlStyle = (percentOffest) => (isRtl(getLocale()) ? { transform: `translateX(${100 - percentOffest}%)` } : {});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-12 col-sm-6 align-self-center p-0">
|
<div className="col-12 col-sm-6 align-self-center p-0">
|
||||||
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
|
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
|
||||||
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
|
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
|
||||||
<g style={{ transform: 'translateY(2.61em)' }}>
|
<g style={{ transform: 'translateY(2.61em)' }}>
|
||||||
<rect className="grade-bar__base" width="100%" />
|
<rect className="grade-bar__base" width="100%" />
|
||||||
<rect className="grade-bar--passing" width={`${passingGrade}%`} style={adjustedRtlStyle(passingGrade)} />
|
<rect className="grade-bar--passing" width={`${passingGrade}%`} />
|
||||||
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} style={adjustedRtlStyle(currentGrade)} />
|
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} />
|
||||||
|
|
||||||
{/* Start divider */}
|
{/* Start divider */}
|
||||||
<rect className="grade-bar__divider" />
|
<rect className="grade-bar__divider" />
|
||||||
@@ -49,7 +45,7 @@ const GradeBar = ({ intl, passingGrade }) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
GradeBar.propTypes = {
|
GradeBar.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useModel } from '../../../../generic/model-store';
|
|||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const GradeRangeTooltip = ({ intl, iconButtonClassName, passingGrade }) => {
|
function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -72,7 +72,7 @@ const GradeRangeTooltip = ({ intl, iconButtonClassName, passingGrade }) => {
|
|||||||
/>
|
/>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
GradeRangeTooltip.defaultProps = {
|
GradeRangeTooltip.defaultProps = {
|
||||||
iconButtonClassName: '',
|
iconButtonClassName: '',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
|
|||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const PassingGradeTooltip = ({ intl, passingGrade, tooltipClassName }) => {
|
function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
|
||||||
const isLocaleRtl = isRtl(getLocale());
|
const isLocaleRtl = isRtl(getLocale());
|
||||||
|
|
||||||
let passingGradeDirection = passingGrade < 50 ? '' : '-';
|
let passingGradeDirection = passingGrade < 50 ? '' : '-';
|
||||||
@@ -25,7 +25,7 @@ const PassingGradeTooltip = ({ intl, passingGrade, tooltipClassName }) => {
|
|||||||
overlay={(
|
overlay={(
|
||||||
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
|
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
|
||||||
<Popover.Content className="text-white">
|
<Popover.Content className="text-white">
|
||||||
{passingGrade}{isLocaleRtl && '\u200f'}%
|
{passingGrade}%
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
@@ -47,7 +47,7 @@ const PassingGradeTooltip = ({ intl, passingGrade, tooltipClassName }) => {
|
|||||||
</text>
|
</text>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
PassingGradeTooltip.defaultProps = {
|
PassingGradeTooltip.defaultProps = {
|
||||||
tooltipClassName: '',
|
tooltipClassName: '',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
@@ -12,14 +13,13 @@ import DetailedGradesTable from './DetailedGradesTable';
|
|||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const DetailedGrades = ({ intl }) => {
|
function DetailedGrades({ intl }) {
|
||||||
const { administrator } = getAuthenticatedUser();
|
const { administrator } = getAuthenticatedUser();
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
const {
|
const {
|
||||||
org,
|
org,
|
||||||
tabs,
|
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courseHomeMeta', courseId);
|
||||||
const {
|
const {
|
||||||
gradesFeatureIsFullyLocked,
|
gradesFeatureIsFullyLocked,
|
||||||
@@ -37,14 +37,11 @@ const DetailedGrades = ({ intl }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const overviewTab = tabs.find(tab => tab.slug === 'outline');
|
const outlineLink = (
|
||||||
const overviewTabUrl = overviewTab && overviewTab.url;
|
|
||||||
|
|
||||||
const outlineLink = overviewTabUrl && (
|
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
variant="muted"
|
variant="muted"
|
||||||
isInline
|
isInline
|
||||||
destination={overviewTabUrl}
|
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
|
||||||
onClick={logOutlineLinkClick}
|
onClick={logOutlineLinkClick}
|
||||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||||
>
|
>
|
||||||
@@ -67,19 +64,17 @@ const DetailedGrades = ({ intl }) => {
|
|||||||
{!hasSectionScores && (
|
{!hasSectionScores && (
|
||||||
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
|
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
|
||||||
)}
|
)}
|
||||||
{overviewTabUrl && (
|
<p className="x-small m-0">
|
||||||
<p className="x-small m-0">
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id="progress.ungradedAlert"
|
||||||
id="progress.ungradedAlert"
|
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
|
||||||
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
|
description="Text that precede link that redirect to course outline page"
|
||||||
description="Text that precede link that redirect to course outline page"
|
values={{ outlineLink }}
|
||||||
values={{ outlineLink }}
|
/>
|
||||||
/>
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
DetailedGrades.propTypes = {
|
DetailedGrades.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import {
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
getLocale, injectIntl, intlShape, isRtl,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { DataTable } from '@edx/paragon';
|
import { DataTable } from '@edx/paragon';
|
||||||
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import SubsectionTitleCell from './SubsectionTitleCell';
|
import SubsectionTitleCell from './SubsectionTitleCell';
|
||||||
|
|
||||||
const DetailedGradesTable = ({ intl }) => {
|
function DetailedGradesTable({ intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -19,7 +17,6 @@ const DetailedGradesTable = ({ intl }) => {
|
|||||||
sectionScores,
|
sectionScores,
|
||||||
} = useModel('progress', courseId);
|
} = useModel('progress', courseId);
|
||||||
|
|
||||||
const isLocaleRtl = isRtl(getLocale());
|
|
||||||
return (
|
return (
|
||||||
sectionScores.map((chapter) => {
|
sectionScores.map((chapter) => {
|
||||||
const subsectionScores = chapter.subsections.filter(
|
const subsectionScores = chapter.subsections.filter(
|
||||||
@@ -35,7 +32,7 @@ const DetailedGradesTable = ({ intl }) => {
|
|||||||
|
|
||||||
const detailedGradesData = subsectionScores.map((subsection) => ({
|
const detailedGradesData = subsectionScores.map((subsection) => ({
|
||||||
subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
|
subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
|
||||||
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}{isLocaleRtl ? '\\' : '/'}{subsection.numPointsPossible}</span>,
|
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}/{subsection.numPointsPossible}</span>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +61,7 @@ const DetailedGradesTable = ({ intl }) => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
DetailedGradesTable.propTypes = {
|
DetailedGradesTable.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -2,27 +2,24 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import {
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
getLocale, injectIntl, intlShape, isRtl,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => {
|
function ProblemScoreDrawer({ intl, problemScores, subsection }) {
|
||||||
const isLocaleRtl = isRtl(getLocale());
|
|
||||||
return (
|
return (
|
||||||
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
|
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
|
||||||
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
|
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
|
||||||
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
|
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
|
||||||
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
|
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
|
||||||
{problemScores.map(problemScore => (
|
{problemScores.map(problemScore => (
|
||||||
<li className="ml-3">{problemScore.earned}{isLocaleRtl ? '\\' : '/'}{problemScore.possible}</li>
|
<li className="ml-3">{problemScore.earned}/{problemScore.possible}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
ProblemScoreDrawer.propTypes = {
|
ProblemScoreDrawer.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import messages from '../messages';
|
|||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
import ProblemScoreDrawer from './ProblemScoreDrawer';
|
import ProblemScoreDrawer from './ProblemScoreDrawer';
|
||||||
|
|
||||||
const SubsectionTitleCell = ({ intl, subsection }) => {
|
function SubsectionTitleCell({ intl, subsection }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -99,7 +99,7 @@ const SubsectionTitleCell = ({ intl, subsection }) => {
|
|||||||
</Collapsible.Body>
|
</Collapsible.Body>
|
||||||
</Collapsible.Advanced>
|
</Collapsible.Advanced>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
SubsectionTitleCell.propTypes = {
|
SubsectionTitleCell.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Icon } from '@edx/paragon';
|
|||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const AssignmentTypeCell = ({
|
function AssignmentTypeCell({
|
||||||
intl, assignmentType, footnoteMarker, footnoteId, locked,
|
intl, assignmentType, footnoteMarker, footnoteId, locked,
|
||||||
}) => {
|
}) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -42,7 +42,7 @@ const AssignmentTypeCell = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
AssignmentTypeCell.propTypes = {
|
AssignmentTypeCell.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
const DroppableAssignmentFootnote = ({ footnotes, intl }) => {
|
function DroppableAssignmentFootnote({ footnotes, intl }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -37,7 +37,7 @@ const DroppableAssignmentFootnote = ({ footnotes, intl }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
DroppableAssignmentFootnote.propTypes = {
|
DroppableAssignmentFootnote.propTypes = {
|
||||||
footnotes: PropTypes.arrayOf(PropTypes.shape({
|
footnotes: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useModel } from '../../../../generic/model-store';
|
|||||||
import GradeSummaryHeader from './GradeSummaryHeader';
|
import GradeSummaryHeader from './GradeSummaryHeader';
|
||||||
import GradeSummaryTable from './GradeSummaryTable';
|
import GradeSummaryTable from './GradeSummaryTable';
|
||||||
|
|
||||||
const GradeSummary = () => {
|
function GradeSummary() {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -28,6 +28,6 @@ const GradeSummary = () => {
|
|||||||
<GradeSummaryTable setAllOfSomeAssignmentTypeIsLocked={setAllOfSomeAssignmentTypeIsLocked} />
|
<GradeSummaryTable setAllOfSomeAssignmentTypeIsLocked={setAllOfSomeAssignmentTypeIsLocked} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default GradeSummary;
|
export default GradeSummary;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Blocked, InfoOutline } from '@edx/paragon/icons';
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => {
|
function GradeSummaryHeader({ intl, allOfSomeAssignmentTypeIsLocked }) {
|
||||||
const {
|
const {
|
||||||
courseId,
|
courseId,
|
||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
@@ -54,7 +54,7 @@ const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
GradeSummaryHeader.propTypes = {
|
GradeSummaryHeader.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user