Compare commits
114 Commits
julianajlk
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbaa41a8ae | ||
|
|
5dab08761c | ||
|
|
e499280e49 | ||
|
|
08c7d2d118 | ||
|
|
17a102d5cf | ||
|
|
77e3b17f03 | ||
|
|
b1d51a0468 | ||
|
|
13f884fc56 | ||
|
|
a701ea5e15 | ||
|
|
108fb314f5 | ||
|
|
75d2abe1a0 | ||
|
|
d00961d85c | ||
|
|
96e2c88837 | ||
|
|
14a4fae421 | ||
|
|
0d1f01628e | ||
|
|
95b285d371 | ||
|
|
1892edaade | ||
|
|
381be9a26b | ||
|
|
b3841ef446 | ||
|
|
5a897e4ea1 | ||
|
|
96ceab8b2f | ||
|
|
f9806d0759 | ||
|
|
a7b584c566 | ||
|
|
193a184142 | ||
|
|
3e76f7ac78 | ||
|
|
36062ff3a6 | ||
|
|
6257cb4b58 | ||
|
|
792d9eb758 | ||
|
|
cd84a15891 | ||
|
|
cafb881a61 | ||
|
|
fd94da0a43 | ||
|
|
1e41547b3e | ||
|
|
bf2f123367 | ||
|
|
0211ecf45e | ||
|
|
36ac129267 | ||
|
|
20d4c35d83 | ||
|
|
bbff8e719e | ||
|
|
5461c08169 | ||
|
|
ee88a12d8f | ||
|
|
9b316bd859 | ||
|
|
7e7eb83596 | ||
|
|
aaa367780d | ||
|
|
6d42ee9c6f | ||
|
|
41047f4c88 | ||
|
|
d83551c809 | ||
|
|
7c3088901d | ||
|
|
518c9ef6c2 | ||
|
|
ae97efaf2b | ||
|
|
361a099ed1 | ||
|
|
7f3757539a | ||
|
|
44f5132e2a | ||
|
|
53b19c9be3 | ||
|
|
abc374b60a | ||
|
|
af837fcac8 | ||
|
|
e328e3d597 | ||
|
|
559160213d | ||
|
|
878a4616f3 | ||
|
|
3028d79597 | ||
|
|
aa0de7663c | ||
|
|
acd91a1c31 | ||
|
|
b32817b3dd | ||
|
|
8b32e5892f | ||
|
|
76cf85f3d7 | ||
|
|
7d86c501a7 | ||
|
|
eeee32c100 | ||
|
|
95d88a054e | ||
|
|
550b15a16c | ||
|
|
715393d6ad | ||
|
|
19b4241020 | ||
|
|
09bd5bd748 | ||
|
|
89771cb56b | ||
|
|
3353ee2f9d | ||
|
|
7c1821382c | ||
|
|
b444d677b7 | ||
|
|
7178f28838 | ||
|
|
b07f22193c | ||
|
|
c6eba42120 | ||
|
|
7bb2266790 | ||
|
|
0a70f9b64e | ||
|
|
cfe4432c6b | ||
|
|
f7219b4f5d | ||
|
|
14a19b2794 | ||
|
|
8a9767cdd3 | ||
|
|
3cba1bbac4 | ||
|
|
9436770620 | ||
|
|
d03dd34009 | ||
|
|
9cdacde4dc | ||
|
|
a22ac3a776 | ||
|
|
7e19af44da | ||
|
|
57c3f3080e | ||
|
|
385635f5d1 | ||
|
|
a7f763cd2a | ||
|
|
c7c9c19771 | ||
|
|
1d3a779ef1 | ||
|
|
4f1a50ec24 | ||
|
|
72d18dc4f9 | ||
|
|
2197ec0c21 | ||
|
|
069ac9c234 | ||
|
|
3edf349969 | ||
|
|
a2516e9fcc | ||
|
|
554806e9ce | ||
|
|
ed13128fc4 | ||
|
|
373a2d88fc | ||
|
|
bcd54a4f4b | ||
|
|
c4cb0e5ac2 | ||
|
|
c77d518d04 | ||
|
|
703250c3d2 | ||
|
|
35ec314505 | ||
|
|
9fc7951576 | ||
|
|
4ed350c9c6 | ||
|
|
ebed27529c | ||
|
|
24ced5dc63 | ||
|
|
f004d0ab3c | ||
|
|
1bbcc6d052 |
1
.env
1
.env
@@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL=''
|
||||
CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
@@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
@@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
|
||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#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
|
||||
10
.github/workflows/validate.yml
vendored
10
.github/workflows/validate.yml
vendored
@@ -2,7 +2,7 @@ name: validate
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
@@ -11,14 +11,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [12, 14, 16]
|
||||
node: [16]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: make validate.ci
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
1
.husky/_/.gitignore
vendored
Normal file
1
.husky/_/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
31
.husky/_/husky.sh
Normal file
31
.husky/_/husky.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
export readonly husky_skip_init=1
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
@@ -1,8 +1,9 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.frontend-app-learning]
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-learning]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
type = KEYVALUEJSON
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -44,7 +44,7 @@ push_translations:
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
tx pull -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
@@ -58,7 +58,6 @@ validate:
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run test
|
||||
npm run build
|
||||
npm run is-es5
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
|
||||
@@ -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
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE
|
||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
Development
|
||||
-----------
|
||||
@@ -23,7 +23,7 @@ Development
|
||||
Start Devstack
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
|
||||
To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
|
||||
- Run ``make dev.up.lms``
|
||||
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
|
||||
@@ -52,7 +52,7 @@ file (which is git-ignored) that defines where to find your local modules, for i
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
Deployment
|
||||
----------
|
||||
|
||||
33884
package-lock.json
generated
33884
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@@ -4,16 +4,14 @@
|
||||
"description": "Frontend learning application.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-app-learning.git"
|
||||
"url": "git+https://github.com/openedx/frontend-app-learning.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
"extends @edx/browserslist-config"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
|
||||
"prepare": "husky install",
|
||||
@@ -23,37 +21,38 @@
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.2.0",
|
||||
"@edx/frontend-component-header": "2.4.3",
|
||||
"@edx/frontend-enterprise-utils": "1.1.1",
|
||||
"@edx/frontend-lib-special-exams": "1.15.5",
|
||||
"@edx/frontend-platform": "1.15.1",
|
||||
"@edx/paragon": "19.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@edx/frontend-component-footer": "11.1.0",
|
||||
"@edx/frontend-component-header": "3.1.0",
|
||||
"@edx/frontend-lib-special-exams": "2.1.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "19.18.3",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.1.16",
|
||||
"@popperjs/core": "2.11.2",
|
||||
"@reduxjs/toolkit": "1.6.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.18",
|
||||
"@popperjs/core": "2.11.5",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.1",
|
||||
"core-js": "3.19.3",
|
||||
"core-js": "3.22.2",
|
||||
"history": "^5.3.0",
|
||||
"js-cookie": "3.0.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.1",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.6",
|
||||
"react-redux": "7.2.8",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-share": "4.4.0",
|
||||
@@ -64,22 +63,17 @@
|
||||
"util": "0.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "9.1.2",
|
||||
"@edx/reactifex": "1.0.3",
|
||||
"@pact-foundation/pact": "9.17.2",
|
||||
"@testing-library/dom": "7.16.3",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "10.3.0",
|
||||
"@edx/browserslist-config": "1.0.2",
|
||||
"@edx/frontend-build": "9.1.4",
|
||||
"@edx/reactifex": "2.0.1",
|
||||
"@pact-foundation/pact": "9.17.3",
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "0.6.6",
|
||||
"axios-mock-adapter": "1.20.0",
|
||||
"codecov": "3.8.3",
|
||||
"enzyme": "3.11.0",
|
||||
"es-check": "6.2.1",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "27.5.1",
|
||||
"jest-chain": "1.1.5",
|
||||
"rosie": "2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
48
src/alerts/active-enteprise-alert/ActiveEnterpriseAlert.jsx
Normal file
48
src/alerts/active-enteprise-alert/ActiveEnterpriseAlert.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { WarningFilled } from '@edx/paragon/icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import genericMessages from './messages';
|
||||
|
||||
function ActiveEnterpriseAlert({ intl, payload }) {
|
||||
const { text, courseId } = payload;
|
||||
const changeActiveEnterprise = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={
|
||||
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
|
||||
`${global.location.origin}/course/${courseId}/home`,
|
||||
)}`
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
{text}
|
||||
<FormattedMessage
|
||||
id="learning.activeEnterprise.alert"
|
||||
description="Prompts the user to log-in with the correct enterprise to access the course content."
|
||||
defaultMessage=" {changeActiveEnterprise}."
|
||||
values={{
|
||||
changeActiveEnterprise,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
ActiveEnterpriseAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
payload: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ActiveEnterpriseAlert);
|
||||
@@ -0,0 +1,27 @@
|
||||
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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
28
src/alerts/active-enteprise-alert/hooks.js
Normal file
28
src/alerts/active-enteprise-alert/hooks.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
|
||||
|
||||
export default function useActiveEnterpriseAlert(courseId) {
|
||||
const { courseAccess } = useModel('courseHomeMeta', courseId);
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. course access code is incorrect_active_enterprise
|
||||
*/
|
||||
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
|
||||
|
||||
const payload = {
|
||||
text: courseAccess && courseAccess.userMessage,
|
||||
courseId,
|
||||
};
|
||||
useAlert(isVisible, {
|
||||
code: 'clientActiveEnterpriseAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
||||
});
|
||||
|
||||
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
|
||||
}
|
||||
3
src/alerts/active-enteprise-alert/index.js
Normal file
3
src/alerts/active-enteprise-alert/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import useActiveEnterpriseAlert from './hooks';
|
||||
|
||||
export default useActiveEnterpriseAlert;
|
||||
11
src/alerts/active-enteprise-alert/messages.js
Normal file
11
src/alerts/active-enteprise-alert/messages.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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 {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedRelativeTime,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
@@ -26,7 +26,7 @@ function CourseStartAlert({ payload }) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
<FormattedRelativeTime
|
||||
key="timeRemaining"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
|
||||
56
src/alerts/sequence-alerts/hooks.js
Normal file
56
src/alerts/sequence-alerts/hooks.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function useSequenceBannerTextAlert(sequenceId) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
// Show Alert that comes along with the sequence
|
||||
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const {
|
||||
entranceExamCurrentScore,
|
||||
entranceExamEnabled,
|
||||
entranceExamId,
|
||||
entranceExamMinimumScorePct,
|
||||
entranceExamPassed,
|
||||
} = course.entranceExamData || {};
|
||||
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
|
||||
let entranceExamText;
|
||||
|
||||
if (entranceExamPassed) {
|
||||
entranceExamText = intl.formatMessage(
|
||||
messages.entranceExamTextPassed, { entranceExamCurrentScore: entranceExamCurrentScore * 100 },
|
||||
);
|
||||
} else {
|
||||
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
|
||||
entranceExamCurrentScore: entranceExamCurrentScore * 100,
|
||||
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
|
||||
});
|
||||
}
|
||||
|
||||
useAlert(entranceExamAlertVisible, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: entranceExamText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };
|
||||
14
src/alerts/sequence-alerts/messages.js
Normal file
14
src/alerts/sequence-alerts/messages.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
entranceExamTextNotPassing: {
|
||||
id: 'learn.sequence.entranceExamTextNotPassing',
|
||||
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
|
||||
},
|
||||
entranceExamTextPassed: {
|
||||
id: 'learn.sequence.entranceExamTextPassed',
|
||||
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory';
|
||||
|
||||
Factory.define('courseHomeMetadata')
|
||||
@@ -9,7 +8,8 @@ Factory.define('courseHomeMetadata')
|
||||
title: 'Demonstration Course',
|
||||
is_self_paced: false,
|
||||
is_enrolled: false,
|
||||
can_load_courseware: true,
|
||||
is_staff: false,
|
||||
can_view_certificate: true,
|
||||
celebrations: null,
|
||||
course_access: {
|
||||
additional_context_user_message: null,
|
||||
@@ -19,7 +19,106 @@ Factory.define('courseHomeMetadata')
|
||||
user_fragment: null,
|
||||
user_message: null,
|
||||
},
|
||||
number: 'DemoX',
|
||||
original_user_is_staff: false,
|
||||
org: 'edX',
|
||||
start: '2013-02-05T05:00:00Z',
|
||||
user_timezone: 'UTC',
|
||||
username: 'MockUser',
|
||||
});
|
||||
verified_mode: {
|
||||
access_expiration_date: null,
|
||||
currency: 'USD',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
sku: '8CF08E5',
|
||||
price: 149,
|
||||
currency_symbol: '$',
|
||||
},
|
||||
})
|
||||
.attr(
|
||||
'tabs', ['id', 'host'], (id, host) => [
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Course',
|
||||
priority: 0,
|
||||
slug: 'courseware',
|
||||
type: 'courseware',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Discussion',
|
||||
priority: 1,
|
||||
slug: 'discussion',
|
||||
type: 'discussion',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'discussion/forum/',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Wiki',
|
||||
priority: 2,
|
||||
slug: 'wiki',
|
||||
type: 'wiki',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'course_wiki',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Progress',
|
||||
priority: 3,
|
||||
slug: 'progress',
|
||||
type: 'progress',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'progress',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Instructor',
|
||||
priority: 4,
|
||||
slug: 'instructor',
|
||||
type: 'instructor',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'instructor',
|
||||
},
|
||||
),
|
||||
Factory.build(
|
||||
'tab',
|
||||
{
|
||||
title: 'Dates',
|
||||
priority: 5,
|
||||
slug: 'dates',
|
||||
type: 'dates',
|
||||
},
|
||||
{
|
||||
courseId: id,
|
||||
host,
|
||||
path: 'dates',
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -35,7 +35,6 @@ Factory.define('outlineTabData')
|
||||
cert_status: null,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
course_goals: {
|
||||
goal_options: [],
|
||||
|
||||
@@ -5,7 +5,6 @@ Factory.define('upgradeNotificationData')
|
||||
.option('dateBlocks', [])
|
||||
.option('offer', null)
|
||||
.option('userTimezone', null)
|
||||
.option('accessExpiration', null)
|
||||
.option('contentTypeGatingEnabled', false)
|
||||
.attr('courseId', 'course-v1:edX+DemoX+Demo_Course')
|
||||
.attr('upsellPageName', 'test')
|
||||
@@ -18,4 +17,9 @@ Factory.define('upgradeNotificationData')
|
||||
upgradeUrl: `${host}/dashboard`,
|
||||
}))
|
||||
.attr('org', 'edX')
|
||||
.attrs({
|
||||
accessExpiration: {
|
||||
expiration_date: '1950-07-13T02:04:49.040006Z',
|
||||
},
|
||||
})
|
||||
.attr('timeOffsetMillis', 0);
|
||||
|
||||
@@ -21,7 +21,7 @@ Object {
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canLoadCourseware": true,
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
@@ -76,9 +76,12 @@ Object {
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -336,7 +339,7 @@ Object {
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canLoadCourseware": true,
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
@@ -391,9 +394,12 @@ Object {
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -405,7 +411,6 @@ Object {
|
||||
"certStatus": null,
|
||||
"certWebViewUrl": null,
|
||||
"certificateAvailableDate": null,
|
||||
"downloadUrl": null,
|
||||
},
|
||||
"courseBlocks": Object {
|
||||
"courses": Object {
|
||||
@@ -439,7 +444,6 @@ Object {
|
||||
"effortTime": 15,
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
|
||||
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
|
||||
"showLink": true,
|
||||
"title": "Title of Sequence",
|
||||
@@ -531,7 +535,7 @@ Object {
|
||||
"models": Object {
|
||||
"courseHomeMeta": Object {
|
||||
"course-v1:edX+DemoX+Demo_Course": Object {
|
||||
"canLoadCourseware": true,
|
||||
"canViewCertificate": true,
|
||||
"celebrations": null,
|
||||
"courseAccess": Object {
|
||||
"additionalContextUserMessage": null,
|
||||
@@ -586,9 +590,12 @@ Object {
|
||||
"userTimezone": "UTC",
|
||||
"username": "MockUser",
|
||||
"verifiedMode": Object {
|
||||
"accessExpirationDate": null,
|
||||
"currency": "USD",
|
||||
"currencySymbol": "$",
|
||||
"price": 10,
|
||||
"upgradeUrl": "test",
|
||||
"price": 149,
|
||||
"sku": "8CF08E5",
|
||||
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -605,7 +612,6 @@ Object {
|
||||
"isPassing": true,
|
||||
"letterGrade": "pass",
|
||||
"percent": 1,
|
||||
"visiblePercent": 1,
|
||||
},
|
||||
"courseId": "course-v1:edX+DemoX+Demo_Course",
|
||||
"creditCourseRequirements": null,
|
||||
|
||||
@@ -90,14 +90,21 @@ function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCourseHomeCourseMetadata(metadata) {
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
||||
* @returns {Object} The normalized metadata
|
||||
*/
|
||||
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
||||
// If needed, we switch it to "outline" here for
|
||||
// use within the MFE to differentiate between course home and courseware.
|
||||
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
|
||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
@@ -141,12 +148,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
effortTime: block.effort_time,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
// The presence of an legacy URL for the sequence indicates that we want this
|
||||
// sequence to be a clickable link in the outline (even though, if the new
|
||||
// courseware experience is active, we will ignore `legacyWebUrl` and build a
|
||||
// link to the MFE ourselves).
|
||||
showLink: !!block.legacy_web_url,
|
||||
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
|
||||
// link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
|
||||
showLink: !!block.lms_web_url,
|
||||
title: block.display_name,
|
||||
};
|
||||
break;
|
||||
@@ -182,11 +186,11 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function getCourseHomeCourseMetadata(courseId) {
|
||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return normalizeCourseHomeCourseMetadata(data);
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
}
|
||||
|
||||
// For debugging purposes, you might like to see a fully loaded dates tab.
|
||||
@@ -201,10 +205,6 @@ export async function getDatesTabData(courseId) {
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||
return {};
|
||||
}
|
||||
if (httpErrorStatus === 401) {
|
||||
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
|
||||
// courseAccess in the metadata call, so just ignore this status for now.
|
||||
@@ -232,16 +232,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// Accumulate the weighted grades by assignment type to calculate the learner facing grade. The grades within
|
||||
// assignmentPolicies have been filtered by what's visible to the learner.
|
||||
camelCasedData.courseGrade.visiblePercent = camelCasedData.gradingPolicy.assignmentPolicies
|
||||
? camelCasedData.gradingPolicy.assignmentPolicies.reduce(
|
||||
(accumulator, assignment) => accumulator + assignment.weightedGrade, 0,
|
||||
) : camelCasedData.courseGrade.percent;
|
||||
|
||||
camelCasedData.courseGrade.isPassing = camelCasedData.courseGrade.visiblePercent
|
||||
>= Math.min(...Object.values(data.grading_policy.grade_range));
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
@@ -300,6 +290,20 @@ export async function getProctoringInfoData(courseId, username) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLiveTabIframe(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||
@@ -317,21 +321,9 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
|
||||
export async function getOutlineTabData(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
|
||||
let { tabData } = {};
|
||||
let requestTime = Date.now();
|
||||
let responseTime = requestTime;
|
||||
try {
|
||||
requestTime = Date.now();
|
||||
tabData = await getAuthenticatedHttpClient().get(url);
|
||||
responseTime = Date.now();
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const requestTime = Date.now();
|
||||
const tabData = await getAuthenticatedHttpClient().get(url);
|
||||
const responseTime = Date.now();
|
||||
|
||||
const {
|
||||
data,
|
||||
|
||||
@@ -58,7 +58,6 @@ describe('Course Home Service', () => {
|
||||
sku: '8CF08E5',
|
||||
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
}),
|
||||
can_load_courseware: boolean(true),
|
||||
celebrations: like({
|
||||
first_section: false,
|
||||
streak_length_to_celebrate: null,
|
||||
@@ -106,7 +105,6 @@ describe('Course Home Service', () => {
|
||||
sku: '8CF08E5',
|
||||
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
|
||||
},
|
||||
canLoadCourseware: true,
|
||||
celebrations: {
|
||||
firstSection: false,
|
||||
streakLengthToCelebrate: null,
|
||||
@@ -139,7 +137,7 @@ describe('Course Home Service', () => {
|
||||
title: 'Demonstration Course',
|
||||
username: 'edx',
|
||||
};
|
||||
const response = await getCourseHomeCourseMetadata(courseId);
|
||||
const response = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
expect(response).toBeTruthy();
|
||||
expect(response).toEqual(normalizedTabData);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
postWeeklyLearningGoal,
|
||||
postDismissWelcomeMessage,
|
||||
postRequestCert,
|
||||
getLiveTabIframe,
|
||||
} from './api';
|
||||
|
||||
import {
|
||||
@@ -32,46 +33,38 @@ const eventTypes = {
|
||||
export function fetchTab(courseId, tab, getTabData, targetUserId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchTabRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseHomeCourseMetadata(courseId),
|
||||
getTabData(courseId, targetUserId),
|
||||
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
|
||||
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
|
||||
const fetchedTabData = tabDataResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedCourseHomeCourseMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(courseHomeCourseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedTabData) {
|
||||
try {
|
||||
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, 'outline');
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeCourseMetadata,
|
||||
},
|
||||
}));
|
||||
const tabDataResult = getTabData && await getTabData(courseId, targetUserId);
|
||||
if (tabDataResult) {
|
||||
dispatch(addModel({
|
||||
modelType: tab,
|
||||
model: {
|
||||
id: courseId,
|
||||
...tabDataResult.value,
|
||||
...tabDataResult,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
logError(tabDataResult.reason);
|
||||
}
|
||||
|
||||
// Disable the access-denied path for now - it caused a regression
|
||||
if (fetchedCourseHomeCourseMetadata && !courseHomeCourseMetadataResult.value.courseAccess.hasAccess) {
|
||||
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
|
||||
dispatch(fetchTabDenied({ courseId }));
|
||||
} else if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
|
||||
dispatch(fetchTabSuccess({ courseId, targetUserId }));
|
||||
} else {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
} else if (tabDataResult || !getTabData) {
|
||||
dispatch(fetchTabSuccess({
|
||||
courseId,
|
||||
targetUserId,
|
||||
}));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
dispatch(fetchTabFailure({ courseId }));
|
||||
logError(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +80,14 @@ export function fetchOutlineTab(courseId) {
|
||||
return fetchTab(courseId, 'outline', getOutlineTabData);
|
||||
}
|
||||
|
||||
export function fetchLiveTab(courseId) {
|
||||
return fetchTab(courseId, 'live', getLiveTabIframe);
|
||||
}
|
||||
|
||||
export function fetchDiscussionTab(courseId) {
|
||||
return fetchTab(courseId, 'discussion');
|
||||
}
|
||||
|
||||
export function dismissWelcomeMessage(courseId) {
|
||||
return async () => postDismissWelcomeMessage(courseId);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('DatesTab', () => {
|
||||
userEvent.hover(tipIcon);
|
||||
const tooltip = screen.getByText(tipText); // now it's there
|
||||
userEvent.unhover(tipIcon);
|
||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
await waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,12 +341,12 @@ describe('DatesTab', () => {
|
||||
|
||||
it('redirects to the home page when unauthenticated', async () => {
|
||||
await renderDenied('authentication_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
|
||||
it('redirects to the home page when unenrolled', async () => {
|
||||
await renderDenied('enrollment_required');
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
36
src/course-home/discussion-tab/DiscussionTab.jsx
Normal file
36
src/course-home/discussion-tab/DiscussionTab.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
|
||||
|
||||
function DiscussionTab() {
|
||||
const { courseId } = useSelector(state => state.courseHome);
|
||||
const { path } = useParams();
|
||||
const [originalPath] = useState(path);
|
||||
const history = useHistory();
|
||||
|
||||
const [, iFrameHeight] = useIFrameHeight();
|
||||
useIFramePluginEvents({
|
||||
'discussions.navigate': (payload) => {
|
||||
const basePath = generatePath('/course/:courseId/discussion', { courseId });
|
||||
history.push(`${basePath}/${payload.path}`);
|
||||
},
|
||||
});
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
|
||||
return (
|
||||
<iframe
|
||||
src={discussionsUrl}
|
||||
className="d-flex w-100 border-0"
|
||||
height={iFrameHeight}
|
||||
style={{ minHeight: '60rem' }}
|
||||
title="discussion"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionTab.propTypes = {};
|
||||
|
||||
export default injectIntl(DiscussionTab);
|
||||
61
src/course-home/discussion-tab/DiscussionTab.test.jsx
Normal file
61
src/course-home/discussion-tab/DiscussionTab.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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)));
|
||||
});
|
||||
});
|
||||
22
src/course-home/live-tab/LiveTab.jsx
Normal file
22
src/course-home/live-tab/LiveTab.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
function LiveTab() {
|
||||
const { courseId } = useSelector(state => state.courseHome);
|
||||
const liveModel = useSelector(state => state.models.live);
|
||||
useEffect(() => {
|
||||
const iframe = document.getElementById('lti-tab-embed');
|
||||
if (iframe) {
|
||||
iframe.className += ' vh-100 w-100 border-0';
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
id="live_tab"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveTab;
|
||||
@@ -28,7 +28,7 @@ export default function LmsHtmlFragment({
|
||||
|
||||
const iframe = useRef(null);
|
||||
function resetIframeHeight() {
|
||||
if (iframe.current) {
|
||||
if (iframe?.current?.contentWindow?.document?.body) {
|
||||
iframe.current.height = iframe.current.contentWindow.document.body.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
@@ -64,6 +65,10 @@ function OutlineTab({ intl }) {
|
||||
verifiedMode,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const {
|
||||
marketingUrl,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const [expandAll, setExpandAll] = useState(false);
|
||||
|
||||
const eventProperties = {
|
||||
@@ -105,6 +110,23 @@ function OutlineTab({ intl }) {
|
||||
/** show post enrolment survey to only B2C learners */
|
||||
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const currentParams = new URLSearchParams(location.search);
|
||||
const startCourse = currentParams.get('start_course');
|
||||
if (startCourse === '1') {
|
||||
sendTrackEvent('welcome.email.clicked.startcourse', {});
|
||||
|
||||
// Deleting the course_start query param as it only needs to be set once
|
||||
// whenever passed in query params.
|
||||
currentParams.delete('start_course');
|
||||
history.replace({
|
||||
search: currentParams.toString(),
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
|
||||
@@ -190,6 +212,7 @@ function OutlineTab({ intl }) {
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Factory } from 'rosie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
@@ -51,9 +52,14 @@ describe('Outline Tab', () => {
|
||||
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
|
||||
}
|
||||
|
||||
async function fetchAndRender() {
|
||||
async function fetchAndRender(path = '') {
|
||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||
await act(async () => render(<OutlineTab />, { store }));
|
||||
await act(async () => render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<OutlineTab />
|
||||
</MemoryRouter>,
|
||||
{ store },
|
||||
));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -138,25 +144,8 @@ describe('Outline Tab', () => {
|
||||
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to legacy courseware', async () => {
|
||||
it('SequenceLink displays link', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: false,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/courses/${courseId}`);
|
||||
});
|
||||
|
||||
it('SequenceLink displays points to courseware MFE', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setMetadata({
|
||||
can_load_courseware: true,
|
||||
});
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
@@ -355,6 +344,26 @@ describe('Outline Tab', () => {
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('post goal via query param', async () => {
|
||||
setTabData({
|
||||
course_goals: {
|
||||
weekly_learning_goal_enabled: true,
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(thunks, 'saveWeeklyLearningGoal');
|
||||
sendTrackEvent.mockClear();
|
||||
|
||||
await fetchAndRender('http://localhost/?weekly_goal=3');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.setgoal', {});
|
||||
});
|
||||
|
||||
it('emit start course event via query param', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
await fetchAndRender('http://localhost/?start_course=1');
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.startcourse', {});
|
||||
});
|
||||
|
||||
describe('weekly learning goal is not set', () => {
|
||||
beforeEach(async () => {
|
||||
setTabData({
|
||||
@@ -568,7 +577,7 @@ describe('Outline Tab', () => {
|
||||
const instructorToolbar = await screen.getByTestId('instructor-toolbar');
|
||||
expect(instructorToolbar).toBeInTheDocument();
|
||||
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
@@ -659,7 +668,6 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.EARNED_NOT_AVAILABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: tomorrow.toISOString(),
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -676,7 +684,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||||
});
|
||||
it('renders verification alert', async () => {
|
||||
const now = new Date();
|
||||
@@ -687,7 +695,6 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -710,7 +717,7 @@ describe('Outline Tab', () => {
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Verify your identity to earn a certificate!')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Verify your identity to qualify for a certificate.')).toBeInTheDocument();
|
||||
});
|
||||
it('renders non passing grade', async () => {
|
||||
const now = new Date();
|
||||
@@ -756,7 +763,6 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -790,50 +796,7 @@ describe('Outline Tab', () => {
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
it('tracks download cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
date_type: 'certificate-available-date',
|
||||
date: tomorrow.toISOString(),
|
||||
title: 'Cert Available',
|
||||
},
|
||||
{
|
||||
date_type: 'verification-deadline-date',
|
||||
date: tomorrow.toISOString(),
|
||||
link_text: 'Verify',
|
||||
title: 'Verification Upgrade Deadline',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
sendTrackEvent.mockClear();
|
||||
const requestingButton = screen.getByRole('button', { name: 'View my certificate' });
|
||||
fireEvent.click(requestingButton);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_downloadable_button.clicked',
|
||||
{
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
org_key: 'edX',
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks unverified cert button', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const now = new Date();
|
||||
@@ -844,7 +807,6 @@ describe('Outline Tab', () => {
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.UNVERIFIED,
|
||||
cert_web_view_url: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -932,7 +894,6 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: 'certificate/testuuid',
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -958,7 +919,6 @@ describe('Outline Tab', () => {
|
||||
cert_status: CERT_STATUS_TYPE.REQUESTING,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: null,
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
@@ -975,33 +935,6 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Certificate (pdf) Complete Alert', () => {
|
||||
it('appears', async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
setMetadata({ is_enrolled: true });
|
||||
setTabData({
|
||||
cert_data: {
|
||||
cert_status: CERT_STATUS_TYPE.DOWNLOADABLE,
|
||||
cert_web_view_url: null,
|
||||
certificate_available_date: null,
|
||||
download_url: 'download/url',
|
||||
},
|
||||
}, {
|
||||
date_blocks: [
|
||||
{
|
||||
date_type: 'course-end-date',
|
||||
date: yesterday.toISOString(),
|
||||
title: 'End',
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Congratulations! Your certificate is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proctoring Info Panel', () => {
|
||||
const onboardingReleaseDate = new Date();
|
||||
onboardingReleaseDate.setDate(new Date().getDate() - 7);
|
||||
@@ -1084,6 +1017,22 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
|
||||
it('displays expiration warning', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
axiosMock.onGet(proctoringInfoUrl).reply(200, {
|
||||
onboarding_status: 'verified',
|
||||
onboarding_link: 'test',
|
||||
expiration_date: expirationDate.toString(),
|
||||
onboarding_release_date: onboardingReleaseDate.toISOString(),
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Onboarding profile review can take 2+ business days.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays expiration warning for other course', async () => {
|
||||
const expirationDate = new Date();
|
||||
// This message will render if the expiration date is within 28 days; set the date 10 days in future
|
||||
expirationDate.setTime(expirationDate.getTime() + 864800000);
|
||||
@@ -1095,7 +1044,23 @@ describe('Outline Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
await screen.findByText('This course contains proctored exams');
|
||||
expect(screen.queryByText('Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Your onboarding profile has been approved. 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
FormattedMessage,
|
||||
FormattedTime,
|
||||
@@ -28,25 +27,16 @@ function SequenceLink({
|
||||
complete,
|
||||
description,
|
||||
due,
|
||||
legacyWebUrl,
|
||||
showLink,
|
||||
title,
|
||||
} = sequence;
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('outline', courseId);
|
||||
const {
|
||||
canLoadCourseware,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
// 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 coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,6 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
courseEndDate,
|
||||
courseId,
|
||||
certURL,
|
||||
isWebCert,
|
||||
userTimezone,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
@@ -66,8 +65,8 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
alertProps.body = (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.cert.when"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and certificates are
|
||||
id="learning.outline.alert.cert.earnedNotAvailable"
|
||||
defaultMessage="This course ends on {courseEndDateFormatted}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certificateAvailableDate}."
|
||||
values={{
|
||||
courseEndDateFormatted,
|
||||
@@ -79,11 +78,7 @@ function CertificateStatusAlert({ intl, payload }) {
|
||||
);
|
||||
} else if (certStatus === CERT_STATUS_TYPE.DOWNLOADABLE) {
|
||||
alertProps.header = intl.formatMessage(certMessages.certStatusDownloadableHeader);
|
||||
if (isWebCert) {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
} else {
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.downloadableButton);
|
||||
}
|
||||
alertProps.buttonMessage = intl.formatMessage(certStatusMessages.viewableButton);
|
||||
alertProps.buttonVisible = true;
|
||||
alertProps.buttonLink = certURL;
|
||||
alertProps.buttonAction = () => {
|
||||
@@ -204,7 +199,6 @@ CertificateStatusAlert.propTypes = {
|
||||
courseEndDate: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
certURL: PropTypes.string,
|
||||
isWebCert: PropTypes.bool,
|
||||
userTimezone: PropTypes.string,
|
||||
org: PropTypes.string,
|
||||
notPassingCourseEnded: PropTypes.bool,
|
||||
|
||||
@@ -51,10 +51,8 @@ function useCertificateStatusAlert(courseId) {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
certificateAvailableDate,
|
||||
downloadUrl,
|
||||
} = certData || {};
|
||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||
const isWebCert = downloadUrl === null;
|
||||
const isVerifiedEnrollmentMode = (
|
||||
enrollmentMode !== null
|
||||
&& enrollmentMode !== undefined
|
||||
@@ -63,9 +61,6 @@ function useCertificateStatusAlert(courseId) {
|
||||
let certURL = '';
|
||||
if (certWebViewUrl) {
|
||||
certURL = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
} else if (downloadUrl) {
|
||||
// PDF Certificate
|
||||
certURL = downloadUrl;
|
||||
}
|
||||
const hasAlertingCertStatus = verifyCertStatusType(certStatus);
|
||||
|
||||
@@ -87,7 +82,6 @@ function useCertificateStatusAlert(courseId) {
|
||||
courseId,
|
||||
courseEndDate: endBlock && endBlock.date,
|
||||
userTimezone,
|
||||
isWebCert,
|
||||
org,
|
||||
notPassingCourseEnded,
|
||||
tabs,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
certStatusEarnedNotAvailableHeader: {
|
||||
id: 'cert.alert.earned.unavailable.header',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
id: 'cert.alert.earned.unavailable.header.v2',
|
||||
defaultMessage: 'Your grade and certificate status will be available soon.',
|
||||
description: 'Header alerting the user that their certificate will be available soon.',
|
||||
},
|
||||
certStatusDownloadableHeader: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelative,
|
||||
FormattedRelativeTime,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
@@ -21,7 +21,7 @@ function CourseEndAlert({ payload }) {
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const timeRemaining = (
|
||||
<FormattedRelative
|
||||
<FormattedRelativeTime
|
||||
key="timeRemaining"
|
||||
value={endDate}
|
||||
{...timezoneFormatArgs}
|
||||
|
||||
@@ -231,6 +231,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Expiring Soon',
|
||||
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
|
||||
},
|
||||
expiredProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expired',
|
||||
defaultMessage: 'Expired',
|
||||
description: 'A label to indicate that proctortrack onboarding exam has expired',
|
||||
},
|
||||
proctoringCurrentStatus: {
|
||||
id: 'learning.proctoringPanel.status',
|
||||
defaultMessage: 'Current Onboarding Status:',
|
||||
@@ -278,9 +283,14 @@ const messages = defineMessages({
|
||||
},
|
||||
expiringSoonProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||
defaultMessage: 'Your onboarding profile has been approved in another course. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
|
||||
},
|
||||
expiredProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expired',
|
||||
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
|
||||
},
|
||||
proctoringPanelGeneralInfo: {
|
||||
id: 'learning.proctoringPanel.generalInfo',
|
||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
||||
|
||||
@@ -35,6 +35,7 @@ function ProctoringInfoPanel({ intl }) {
|
||||
error: 'error',
|
||||
otherCourseApproved: 'otherCourseApproved',
|
||||
expiringSoon: 'expiringSoon',
|
||||
expired: 'expired',
|
||||
};
|
||||
|
||||
function getReadableStatusClass(examStatus) {
|
||||
@@ -54,9 +55,14 @@ function ProctoringInfoPanel({ intl }) {
|
||||
return readableClass;
|
||||
}
|
||||
|
||||
function isNotYetSubmitted(examStatus) {
|
||||
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
|
||||
return !NO_SHOW_STATES.includes(examStatus);
|
||||
function isCurrentlySubmitted(examStatus) {
|
||||
const SUBMITTED_STATES = ['submitted', 'second_review_required'];
|
||||
return SUBMITTED_STATES.includes(examStatus);
|
||||
}
|
||||
|
||||
function isSubmissionRequired(examStatus) {
|
||||
const OK_STATES = [readableStatuses.submitted, readableStatuses.verified];
|
||||
return !OK_STATES.includes(examStatus);
|
||||
}
|
||||
|
||||
function isNotYetReleased(examReleaseDate) {
|
||||
@@ -77,11 +83,19 @@ function ProctoringInfoPanel({ intl }) {
|
||||
return borderClass;
|
||||
}
|
||||
|
||||
function isExpiringSoon(dateString) {
|
||||
// Returns true if the expiration date is within 28 days
|
||||
function isExpired(dateString) {
|
||||
// Returns true if the expiration date has passed
|
||||
const today = new Date();
|
||||
const expirationDateObject = new Date(dateString);
|
||||
return today > expirationDateObject.getTime() - 2419200000;
|
||||
return today >= expirationDateObject.getTime();
|
||||
}
|
||||
|
||||
function isExpiringSoon(dateString) {
|
||||
// Returns true if the expiration date is within 28 days
|
||||
const twentyeightDays = 28 * 24 * 60 * 60 * 1000;
|
||||
const today = new Date();
|
||||
const expirationDateObject = new Date(dateString);
|
||||
return today > expirationDateObject.getTime() - twentyeightDays;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,7 +110,9 @@ function ProctoringInfoPanel({ intl }) {
|
||||
setStatus(response.onboarding_status);
|
||||
setLink(response.onboarding_link);
|
||||
const expirationDate = response.expiration_date;
|
||||
if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||
if (expirationDate && isExpired(expirationDate)) {
|
||||
setReadableStatus(getReadableStatusClass('expired'));
|
||||
} else if (expirationDate && isExpiringSoon(expirationDate)) {
|
||||
setReadableStatus(getReadableStatusClass('expiringSoon'));
|
||||
} else {
|
||||
setReadableStatus(getReadableStatusClass(response.onboarding_status));
|
||||
@@ -175,17 +191,17 @@ function ProctoringInfoPanel({ intl }) {
|
||||
{![readableStatuses.verified, readableStatuses.otherCourseApproved].includes(readableStatus) && (
|
||||
<>
|
||||
<p>
|
||||
{isNotYetSubmitted(status) && (
|
||||
{!isCurrentlySubmitted(status) && (
|
||||
intl.formatMessage(messages.proctoringPanelGeneralInfo)
|
||||
)}
|
||||
{!isNotYetSubmitted(status) && (
|
||||
{isCurrentlySubmitted(status) && (
|
||||
intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)
|
||||
)}
|
||||
</p>
|
||||
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
|
||||
</>
|
||||
)}
|
||||
{isNotYetSubmitted(status) && (
|
||||
{isSubmissionRequired(readableStatus) && (
|
||||
onboardingExamButton
|
||||
)}
|
||||
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
|
||||
|
||||
@@ -56,7 +56,7 @@ function StartOrResumeCourseCard({ intl }) {
|
||||
)}
|
||||
/>
|
||||
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
|
||||
<Card.Footer />
|
||||
<Card.Footer><></></Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Form, Card, Icon } from '@edx/paragon';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -32,8 +34,9 @@ function WeeklyLearningGoalCard({
|
||||
const [daysPerWeekGoal, setDaysPerWeekGoal] = useState(daysPerWeek);
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
|
||||
const location = useLocation();
|
||||
|
||||
function handleSelect(days) {
|
||||
function handleSelect(days, triggeredFromEmail = false) {
|
||||
// Set the subscription button if this is the first time selecting a goal
|
||||
const selectReminders = daysPerWeekGoal === null ? true : isGetReminderSelected;
|
||||
setGetReminderSelected(selectReminders);
|
||||
@@ -47,6 +50,9 @@ function WeeklyLearningGoalCard({
|
||||
num_days: days,
|
||||
reminder_selected: selectReminders,
|
||||
});
|
||||
if (triggeredFromEmail) {
|
||||
sendTrackEvent('welcome.email.clicked.setgoal', {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +71,21 @@ function 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(),
|
||||
});
|
||||
}
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
id="courseHome-weeklyLearningGoal"
|
||||
|
||||
@@ -31,6 +31,9 @@ describe('Progress Tab', () => {
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
|
||||
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
|
||||
@@ -133,6 +136,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -166,6 +171,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('0%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('0%');
|
||||
expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -213,6 +220,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('button', { name: 'Grade range tooltip' }));
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('80%');
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('80%');
|
||||
expect(await screen.findByText('You’re currently passing this course with a grade of B (80-90%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -442,9 +451,8 @@ describe('Progress Tab', () => {
|
||||
expect(screen.queryAllByTestId('blocked-icon')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders correct current grade tooltip when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
it('does not render subsections for which showGrades is false', async () => {
|
||||
// The second assignment has showGrades set to false, so it should not be shown.
|
||||
setTabData({
|
||||
section_scores: [
|
||||
{
|
||||
@@ -486,10 +494,8 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('currentGradeTooltipContent').innerHTML).toEqual('50%');
|
||||
// Although the learner's true grade is passing, we should expect this to reflect the grade that's
|
||||
// visible to them, which is non-passing
|
||||
expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument();
|
||||
expect(screen.getByText('First subsection')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second subsection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct title when credit information is available', async () => {
|
||||
@@ -661,52 +667,6 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct total weighted grade when showGrades is false', async () => {
|
||||
// The learner has a 50% on the first assignment and a 100% on the second, making their grade a 75%
|
||||
// The second assignment has showGrades set to false, so the grade reflected to the learner should be 50%.
|
||||
setTabData({
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
display_name: 'First subsection',
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 2,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/first_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Second section',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
display_name: 'Second subsection',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
num_points_earned: 1,
|
||||
num_points_possible: 1,
|
||||
percent_graded: 1.0,
|
||||
show_correctness: 'always',
|
||||
show_grades: false,
|
||||
url: 'http://learning.edx.org/course/course-v1:edX+Test+run/second_subsection',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
expect(screen.getByTestId('gradeSummaryFooterTotalWeightedGrade').innerHTML).toEqual('50%');
|
||||
});
|
||||
|
||||
it('renders override notice', async () => {
|
||||
setTabData({
|
||||
section_scores: [
|
||||
@@ -999,49 +959,6 @@ describe('Progress Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays download link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
user_has_passing_grade: true,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
track_variant: 'audit',
|
||||
grade_variant: 'passing',
|
||||
certificate_status_variant: 'earned_downloadable',
|
||||
});
|
||||
|
||||
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
|
||||
fireEvent.click(downloadCertificateLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: courseId,
|
||||
is_staff: false,
|
||||
certificate_status_variant: 'earned_downloadable',
|
||||
});
|
||||
});
|
||||
|
||||
it('Displays webview link', async () => {
|
||||
setTabData({
|
||||
certificate_data: {
|
||||
@@ -1263,6 +1180,66 @@ describe('Progress Tab', () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
certificate_data: undefined,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: true,
|
||||
certificate_data: undefined,
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}.`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows certificate_available_date if learner is passing', async () => {
|
||||
setMetadata({
|
||||
can_view_certificate: false,
|
||||
is_enrolled: true,
|
||||
});
|
||||
setTabData({
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: true,
|
||||
certificate_data: {
|
||||
cert_status: 'earned_but_not_available',
|
||||
certificate_available_date: overmorrow.toISOString(),
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Certificate status'));
|
||||
expect(screen.getByText(
|
||||
overmorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
{ exact: false },
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credit Information', () => {
|
||||
@@ -1326,7 +1303,7 @@ describe('Progress Tab', () => {
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
setMetadata({ is_enrolled: true, original_user_is_staff: true });
|
||||
@@ -1339,7 +1316,7 @@ describe('Progress Tab', () => {
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2020', { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1355,7 +1332,7 @@ describe('Progress Tab', () => {
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
|
||||
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2999')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/1/2999', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
it('does not render banner when not masquerading', async () => {
|
||||
setMetadata({
|
||||
@@ -1367,7 +1344,7 @@ describe('Progress Tab', () => {
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
|
||||
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2999')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('1/1/2999', { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ function CertificateStatus({ intl }) {
|
||||
const {
|
||||
isEnrolled,
|
||||
org,
|
||||
canViewCertificate,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
@@ -45,6 +47,8 @@ function CertificateStatus({ intl }) {
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
null, // CourseExitPageIsActive
|
||||
canViewCertificate,
|
||||
);
|
||||
|
||||
const eventProperties = {
|
||||
@@ -57,12 +61,11 @@ function CertificateStatus({ intl }) {
|
||||
|
||||
let certStatus;
|
||||
let certWebViewUrl;
|
||||
let downloadUrl;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
if (certificateData) {
|
||||
certStatus = certificateData.certStatus;
|
||||
certWebViewUrl = certificateData.certWebViewUrl;
|
||||
downloadUrl = certificateData.downloadUrl;
|
||||
}
|
||||
|
||||
let certCase;
|
||||
@@ -138,15 +141,10 @@ function CertificateStatus({ intl }) {
|
||||
values={{ dashboardLink, profileLink }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (certWebViewUrl) {
|
||||
certEventName = 'earned_viewable';
|
||||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
buttonText = intl.formatMessage(messages.viewableButton);
|
||||
} else if (downloadUrl) {
|
||||
certEventName = 'earned_downloadable';
|
||||
buttonLocation = downloadUrl;
|
||||
buttonText = intl.formatMessage(messages.downloadableButton);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -157,7 +155,7 @@ function CertificateStatus({ intl }) {
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="This course ends on {endDate}. Final grades and certificates are
|
||||
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certAvailabilityDate}."
|
||||
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
|
||||
values={{ endDate, certAvailabilityDate }}
|
||||
@@ -178,10 +176,22 @@ function CertificateStatus({ intl }) {
|
||||
}
|
||||
break;
|
||||
|
||||
// This code shouldn't be hit but coding defensively since switch expects a default statement
|
||||
default:
|
||||
certCase = null;
|
||||
certEventName = 'no_certificate_status';
|
||||
// if user completes a course before certificates are available, treat it as notAvailable
|
||||
// regardless of passing or nonpassing status
|
||||
if (!canViewCertificate) {
|
||||
certCase = 'notAvailable';
|
||||
endDate = intl.formatDate(end, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
...timezoneFormatArgs,
|
||||
});
|
||||
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
|
||||
} else {
|
||||
certCase = null;
|
||||
certEventName = 'no_certificate_status';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.',
|
||||
description: 'Recommending an action for learner when course certificate is available',
|
||||
},
|
||||
downloadableButton: {
|
||||
id: 'progress.certificateStatus.downloadableButton',
|
||||
defaultMessage: 'Download my certificate',
|
||||
description: 'Button text when learner certifcate status is downloadable',
|
||||
},
|
||||
viewableButton: {
|
||||
id: 'progress.certificateStatus.viewableButton',
|
||||
defaultMessage: 'View my certificate',
|
||||
@@ -76,6 +71,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Certificate status',
|
||||
description: 'Header text when the certifcate is not available',
|
||||
},
|
||||
notAvailableEndDateBody: {
|
||||
id: 'progress.certificateBody.notAvailable.endDate',
|
||||
defaultMessage: 'Final grades and any earned certificates are scheduled to be available after {endDate}.',
|
||||
description: 'Shown for learners who have finished a course before grades and certificates are available.',
|
||||
},
|
||||
upgradeHeader: {
|
||||
id: 'progress.certificateStatus.upgradeHeader',
|
||||
defaultMessage: 'Earn a certificate',
|
||||
@@ -92,8 +92,8 @@ const messages = defineMessages({
|
||||
description: 'Button text which leaner needs to upgrade to get the certifcate',
|
||||
},
|
||||
unverifiedHomeHeader: {
|
||||
id: 'progress.certificateStatus.unverifiedHomeHeader',
|
||||
defaultMessage: 'Verify your identity to earn a certificate!',
|
||||
id: 'progress.certificateStatus.unverifiedHomeHeader.v2',
|
||||
defaultMessage: 'Verify your identity to qualify for a certificate.',
|
||||
description: 'Header text when the learner needs to do verification to earn a certifcate ',
|
||||
},
|
||||
unverifiedHomeButton: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
getLocale, injectIntl, intlShape, isRtl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
import CompleteDonutSegment from './CompleteDonutSegment';
|
||||
@@ -26,6 +28,8 @@ function CompletionDonutChart({ intl }) {
|
||||
const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0;
|
||||
const incompletePercentage = 100 - completePercentage - lockedPercentage;
|
||||
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg role="img" width="50%" height="100%" viewBox="0 0 42 42" className="donut" style={{ maxWidth: '178px' }} aria-hidden="true">
|
||||
@@ -35,7 +39,7 @@ function CompletionDonutChart({ intl }) {
|
||||
<circle className="donut-hole" fill="#fff" cx="21" cy="21" r="15.91549430918954" />
|
||||
<g className="donut-chart-text">
|
||||
<text x="50%" y="50%" className="donut-chart-number">
|
||||
{completePercentage}%
|
||||
{completePercentage}{isLocaleRtl && '\u200f'}%
|
||||
</text>
|
||||
<text x="50%" y="50%" className="donut-chart-label">
|
||||
{intl.formatMessage(messages.donutLabel)}
|
||||
|
||||
@@ -19,11 +19,11 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
percent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
const currentGrade = Number((percent * 100).toFixed(0));
|
||||
|
||||
let currentGradeDirection = currentGrade < 50 ? '' : '-';
|
||||
|
||||
@@ -41,7 +41,7 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
|
||||
overlay={(
|
||||
<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'}>
|
||||
{currentGrade.toFixed(0)}%
|
||||
{currentGrade.toFixed(0)}{isLocaleRtl ? '\u200f' : ''}%
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
getLocale, injectIntl, intlShape, isRtl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import CurrentGradeTooltip from './CurrentGradeTooltip';
|
||||
import PassingGradeTooltip from './PassingGradeTooltip';
|
||||
@@ -17,23 +19,25 @@ function GradeBar({ intl, passingGrade }) {
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
percent,
|
||||
},
|
||||
gradesFeatureIsFullyLocked,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const currentGrade = Number((visiblePercent * 100).toFixed(0));
|
||||
const currentGrade = Number((percent * 100).toFixed(0));
|
||||
|
||||
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
|
||||
|
||||
const adjustedRtlStyle = (percentOffest) => (isRtl(getLocale()) ? { transform: `translateX(${100 - percentOffest}%)` } : {});
|
||||
|
||||
return (
|
||||
<div className="col-12 col-sm-6 align-self-center p-0">
|
||||
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
|
||||
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
|
||||
<g style={{ transform: 'translateY(2.61em)' }}>
|
||||
<rect className="grade-bar__base" width="100%" />
|
||||
<rect className="grade-bar--passing" width={`${passingGrade}%`} />
|
||||
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} />
|
||||
<rect className="grade-bar--passing" width={`${passingGrade}%`} style={adjustedRtlStyle(passingGrade)} />
|
||||
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} style={adjustedRtlStyle(currentGrade)} />
|
||||
|
||||
{/* Start divider */}
|
||||
<rect className="grade-bar__divider" />
|
||||
|
||||
@@ -25,7 +25,7 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
|
||||
overlay={(
|
||||
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
|
||||
<Popover.Content className="text-white">
|
||||
{passingGrade}%
|
||||
{passingGrade}{isLocaleRtl && '\u200f'}%
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -20,6 +19,7 @@ function DetailedGrades({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
tabs,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
gradesFeatureIsFullyLocked,
|
||||
@@ -37,11 +37,14 @@ function DetailedGrades({ intl }) {
|
||||
});
|
||||
};
|
||||
|
||||
const outlineLink = (
|
||||
const overviewTab = tabs.find(tab => tab.slug === 'outline');
|
||||
const overviewTabUrl = overviewTab && overviewTab.url;
|
||||
|
||||
const outlineLink = overviewTabUrl && (
|
||||
<Hyperlink
|
||||
variant="muted"
|
||||
isInline
|
||||
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`}
|
||||
destination={overviewTabUrl}
|
||||
onClick={logOutlineLinkClick}
|
||||
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
|
||||
>
|
||||
@@ -64,14 +67,16 @@ function DetailedGrades({ intl }) {
|
||||
{!hasSectionScores && (
|
||||
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
|
||||
)}
|
||||
<p className="x-small m-0">
|
||||
<FormattedMessage
|
||||
id="progress.ungradedAlert"
|
||||
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
|
||||
description="Text that precede link that redirect to course outline page"
|
||||
values={{ outlineLink }}
|
||||
/>
|
||||
</p>
|
||||
{overviewTabUrl && (
|
||||
<p className="x-small m-0">
|
||||
<FormattedMessage
|
||||
id="progress.ungradedAlert"
|
||||
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
|
||||
description="Text that precede link that redirect to course outline page"
|
||||
values={{ outlineLink }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
getLocale, injectIntl, intlShape, isRtl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { DataTable } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
@@ -17,6 +19,7 @@ function DetailedGradesTable({ intl }) {
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
return (
|
||||
sectionScores.map((chapter) => {
|
||||
const subsectionScores = chapter.subsections.filter(
|
||||
@@ -32,7 +35,7 @@ function DetailedGradesTable({ intl }) {
|
||||
|
||||
const detailedGradesData = subsectionScores.map((subsection) => ({
|
||||
subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
|
||||
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}/{subsection.numPointsPossible}</span>,
|
||||
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}{isLocaleRtl ? '\\' : '/'}{subsection.numPointsPossible}</span>,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,18 +2,21 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
getLocale, injectIntl, intlShape, isRtl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function ProblemScoreDrawer({ intl, problemScores, subsection }) {
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
return (
|
||||
<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>
|
||||
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
|
||||
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
|
||||
{problemScores.map(problemScore => (
|
||||
<li className="ml-3">{problemScore.earned}/{problemScore.possible}</li>
|
||||
<li className="ml-3">{problemScore.earned}{isLocaleRtl ? '\\' : '/'}{problemScore.possible}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
getLocale, injectIntl, intlShape, isRtl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { DataTable } from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
@@ -66,13 +68,15 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
|
||||
|
||||
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignment.type);
|
||||
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
return {
|
||||
type: {
|
||||
footnoteId, footnoteMarker, type: assignment.type, locked,
|
||||
},
|
||||
weight: { weight: `${(assignment.weight * 100).toFixed(0)}%`, locked },
|
||||
grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}%`, locked },
|
||||
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}%`, locked },
|
||||
weight: { weight: `${(assignment.weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
grade: { grade: `${(assignment.averageGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
getLocale, injectIntl, intlShape, isRtl,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { DataTable } from '@edx/paragon';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
@@ -15,18 +17,20 @@ function GradeSummaryTableFooter({ intl }) {
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
visiblePercent,
|
||||
percent,
|
||||
},
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
|
||||
const totalGrade = (visiblePercent * 100).toFixed(0);
|
||||
const totalGrade = (percent * 100).toFixed(0);
|
||||
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
return (
|
||||
<DataTable.TableFooter className={`border-top border-primary ${bgColor}`}>
|
||||
<div className="row w-100 m-0">
|
||||
<div id="weighted-grade-summary" className="col-8 p-0 small">{intl.formatMessage(messages.weightedGradeSummary)}</div>
|
||||
<div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}%</div>
|
||||
<div data-testid="gradeSummaryFooterTotalWeightedGrade" aria-labelledby="weighted-grade-summary" className="col-4 p-0 text-right font-weight-bold small">{totalGrade}{isLocaleRtl && '\u200f'}%</div>
|
||||
</div>
|
||||
</DataTable.TableFooter>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -16,6 +15,7 @@ function RelatedLinks({ intl }) {
|
||||
} = useSelector(state => state.courseHome);
|
||||
const {
|
||||
org,
|
||||
tabs,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
@@ -28,22 +28,31 @@ function RelatedLinks({ intl }) {
|
||||
});
|
||||
};
|
||||
|
||||
const overviewTab = tabs.find(tab => tab.slug === 'outline');
|
||||
const overviewTabUrl = overviewTab && overviewTab.url;
|
||||
const datesTab = tabs.find(tab => tab.slug === 'dates');
|
||||
const datesTabUrl = datesTab && datesTab.url;
|
||||
|
||||
return (
|
||||
<section className="mb-4 x-small">
|
||||
<h3 className="h4">{intl.formatMessage(messages.relatedLinks)}</h3>
|
||||
<ul className="pl-4">
|
||||
{datesTabUrl && (
|
||||
<li>
|
||||
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`} onClick={() => logLinkClicked('dates')}>
|
||||
<Hyperlink destination={datesTabUrl} onClick={() => logLinkClicked('dates')}>
|
||||
{intl.formatMessage(messages.datesCardLink)}
|
||||
</Hyperlink>
|
||||
<p>{intl.formatMessage(messages.datesCardDescription)}</p>
|
||||
</li>
|
||||
)}
|
||||
{overviewTabUrl && (
|
||||
<li>
|
||||
<Hyperlink destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`} onClick={() => logLinkClicked('course_outline')}>
|
||||
<Hyperlink destination={overviewTabUrl} onClick={() => logLinkClicked('course_outline')}>
|
||||
{intl.formatMessage(messages.outlineCardLink)}
|
||||
</Hyperlink>
|
||||
<p>{intl.formatMessage(messages.outlineCardDescription)}</p>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -23,12 +23,10 @@ describe('Course Tabs Navigation', () => {
|
||||
};
|
||||
render(<CourseTabsNavigation {...mockData} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[0].title }))
|
||||
.toHaveAttribute('href', tabs[0].url)
|
||||
.toHaveClass('active');
|
||||
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveAttribute('href', tabs[0].url);
|
||||
expect(screen.getByRole('link', { name: tabs[0].title })).toHaveClass('active');
|
||||
|
||||
expect(screen.getByRole('link', { name: tabs[1].title }))
|
||||
.toHaveAttribute('href', tabs[1].url)
|
||||
.not.toHaveClass('active');
|
||||
expect(screen.getByRole('link', { name: tabs[1].title })).toHaveAttribute('href', tabs[1].url);
|
||||
expect(screen.getByRole('link', { name: tabs[1].title })).not.toHaveClass('active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('CoursewareContainer', () => {
|
||||
sequenceBlocks: [defaultSequenceBlock],
|
||||
} = buildSimpleCourseBlocks(
|
||||
defaultCourseId,
|
||||
defaultCourseMetadata.name,
|
||||
defaultCourseHomeMetadata.title,
|
||||
{ unitBlocks: defaultUnitBlocks },
|
||||
);
|
||||
|
||||
@@ -147,6 +147,9 @@ describe('CoursewareContainer', () => {
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${unitBlock.id}`;
|
||||
axiosMock.onGet(sequenceMetadataUrl).reply(422, {});
|
||||
});
|
||||
|
||||
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
|
||||
axiosMock.onGet(discussionConfigUrl).reply(200, { provider: 'legacy' });
|
||||
}
|
||||
|
||||
async function loadContainer() {
|
||||
@@ -170,15 +173,16 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
describe('when receiving successful course data', () => {
|
||||
const courseMetadata = defaultCourseMetadata;
|
||||
const courseHomeMetadata = defaultCourseHomeMetadata;
|
||||
const courseId = defaultCourseId;
|
||||
|
||||
function assertLoadedHeader(container) {
|
||||
const courseHeader = container.querySelector('.learning-header');
|
||||
// Ensure the course number and org appear - this proves we loaded course metadata properly.
|
||||
expect(courseHeader).toHaveTextContent(courseMetadata.number);
|
||||
expect(courseHeader).toHaveTextContent(courseMetadata.org);
|
||||
expect(courseHeader).toHaveTextContent(courseHomeMetadata.number);
|
||||
expect(courseHeader).toHaveTextContent(courseHomeMetadata.org);
|
||||
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
|
||||
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
|
||||
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseHomeMetadata.title);
|
||||
}
|
||||
|
||||
function assertSequenceNavigation(container, expectedUnitCount = 3) {
|
||||
@@ -247,7 +251,7 @@ describe('CoursewareContainer', () => {
|
||||
const {
|
||||
courseBlocks, unitTree, sequenceTree, sectionTree,
|
||||
} = buildBinaryCourseBlocks(
|
||||
courseId, courseMetadata.name,
|
||||
courseId, courseHomeMetadata.title,
|
||||
);
|
||||
|
||||
function setUrl(urlSequenceId, urlUnitId = null) {
|
||||
@@ -446,6 +450,7 @@ describe('CoursewareContainer', () => {
|
||||
has_access: false,
|
||||
error_code: errorCode,
|
||||
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
|
||||
developer_message: 'data_sharing_consent_url', // only used by data_sharing_access_required
|
||||
},
|
||||
});
|
||||
|
||||
@@ -461,7 +466,7 @@ describe('CoursewareContainer', () => {
|
||||
const { courseMetadata } = setUpWithDeniedStatus('enrollment_required');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
|
||||
it('should go to course survey for a survey_required error code', async () => {
|
||||
@@ -471,11 +476,25 @@ describe('CoursewareContainer', () => {
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/survey/${courseMetadata.id}`);
|
||||
});
|
||||
|
||||
it('should go to consent page for a data_sharing_access_required error code', async () => {
|
||||
setUpWithDeniedStatus('data_sharing_access_required');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual('http://localhost/redirect/consent?consentPath=data_sharing_consent_url');
|
||||
});
|
||||
|
||||
it('should go to access denied page for a incorrect_active_enterprise error code', async () => {
|
||||
const { courseMetadata } = setUpWithDeniedStatus('incorrect_active_enterprise');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/access-denied`);
|
||||
});
|
||||
|
||||
it('should go to course home for an authentication_required error code', async () => {
|
||||
const { courseMetadata } = setUpWithDeniedStatus('authentication_required');
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
|
||||
});
|
||||
|
||||
it('should go to dashboard for an unfulfilled_milestones error code', async () => {
|
||||
@@ -500,21 +519,4 @@ describe('CoursewareContainer', () => {
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirects when canLoadCourseware is false', () => {
|
||||
it('should go to legacy courseware for disabled frontend', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', {
|
||||
can_load_courseware: false,
|
||||
});
|
||||
const courseId = courseMetadata.id;
|
||||
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
|
||||
setUpMockRequests({ courseBlocks, courseMetadata, courseHomeMetadata });
|
||||
history.push(`/course/${courseId}/${sequenceBlocks[0].id}/${unitBlocks[0].id}`);
|
||||
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/redirect/courseware/${courseMetadata.id}/unit/${unitBlocks[0].id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
|
||||
import queryString from 'query-string';
|
||||
import PageLoading from '../generic/PageLoading';
|
||||
|
||||
export default () => {
|
||||
@@ -20,18 +21,6 @@ export default () => {
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<PageRoute
|
||||
path={`${path}/courseware/:courseId/unit/:unitId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/jump_to/${match.params.unitId}?experience=legacy`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/course-home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/survey/:courseId`}
|
||||
render={({ match }) => {
|
||||
@@ -44,6 +33,19 @@ export default () => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/consent/`}
|
||||
render={({ location }) => {
|
||||
const { consentPath } = queryString.parse(location.search);
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`/course/${match.params.courseId}/home`);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
|
||||
52
src/courseware/CoursewareRedirectLandingPage.test.jsx
Normal file
52
src/courseware/CoursewareRedirectLandingPage.test.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Router } from 'react-router';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { render, initializeMockApp } from '../setupTest';
|
||||
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
|
||||
|
||||
const redirectUrl = jest.fn();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useRouteMatch: () => ({
|
||||
path: '/redirect',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CoursewareRedirectLandingPage', () => {
|
||||
beforeEach(async () => {
|
||||
await initializeMockApp();
|
||||
delete global.location;
|
||||
global.location = { assign: redirectUrl };
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/consent/?consentPath=%2Fgrant_data_sharing_consent'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router history={history}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
expect(redirectUrl).toHaveBeenCalledWith('http://localhost:18000/grant_data_sharing_consent');
|
||||
});
|
||||
|
||||
it('Redirects to correct consent URL', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/redirect/home/course-v1:edX+DemoX+Demo_Course'],
|
||||
});
|
||||
|
||||
render(
|
||||
<Router history={history}>
|
||||
<CoursewareRedirectLandingPage />
|
||||
</Router>,
|
||||
);
|
||||
|
||||
expect(redirectUrl).toHaveBeenCalledWith('/course/course-v1:edX+DemoX+Demo_Course/home');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -12,10 +12,10 @@ import Sequence from './sequence';
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import NotificationTrigger from './NotificationTrigger';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import SidebarTriggers from './sidebar/SidebarTriggers';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
|
||||
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
@@ -31,6 +31,10 @@ function Course({
|
||||
windowWidth,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
celebrations,
|
||||
isStaff,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
|
||||
@@ -40,27 +44,19 @@ function Course({
|
||||
course,
|
||||
].filter(element => element != null).map(element => element.title);
|
||||
|
||||
const {
|
||||
celebrations,
|
||||
courseGoals,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad(
|
||||
courseId, sequenceId, celebrateFirstSection, dispatch, celebrations,
|
||||
));
|
||||
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false);
|
||||
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
|
||||
// the weekly goal celebration modal.
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const daysPerWeek = courseGoals?.selectedGoal?.daysPerWeek;
|
||||
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
// Responsive breakpoints for showing the notification button/tray
|
||||
const shouldDisplayNotificationTriggerInCourse = windowWidth >= breakpoints.small.minWidth;
|
||||
const shouldDisplayNotificationTrayOpenOnLoad = windowWidth > breakpoints.medium.minWidth;
|
||||
|
||||
// Course specific notification tray open/closed persistance by browser session
|
||||
@@ -73,65 +69,38 @@ function Course({
|
||||
}
|
||||
}
|
||||
|
||||
const [notificationTrayVisible, setNotificationTray] = verifiedMode
|
||||
&& shouldDisplayNotificationTrayOpenOnLoad && getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed' ? useState(true) : useState(false);
|
||||
|
||||
const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray;
|
||||
|
||||
const toggleNotificationTray = () => {
|
||||
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
|
||||
if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
|
||||
} else {
|
||||
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
|
||||
}
|
||||
};
|
||||
|
||||
if (!getLocalStorage(`notificationStatus.${courseId}`)) {
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'active'); // Show red dot on notificationTrigger until seen
|
||||
}
|
||||
|
||||
if (!getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)) {
|
||||
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'initialize');
|
||||
}
|
||||
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setupgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
const onNotificationSeen = () => {
|
||||
setNotificationStatus('inactive');
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'inactive');
|
||||
};
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
|
||||
|
||||
useEffect(() => {
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
|
||||
courseId,
|
||||
sequenceId,
|
||||
celebrateFirstSection,
|
||||
dispatch,
|
||||
celebrations,
|
||||
));
|
||||
}, [sequenceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider courseId={courseId} unitId={unitId}>
|
||||
<Helmet>
|
||||
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
<div className="position-relative">
|
||||
<div className="position-relative d-flex align-items-start">
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
isStaff={course ? course.isStaff : null}
|
||||
isStaff={isStaff}
|
||||
unitId={unitId}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
|
||||
{ shouldDisplayNotificationTriggerInCourse ? (
|
||||
<NotificationTrigger
|
||||
courseId={courseId}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
setNotificationStatus={setNotificationStatus}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
/>
|
||||
) : null}
|
||||
{shouldDisplayTriggers && (
|
||||
<SidebarTriggers />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertList topic="sequence" />
|
||||
@@ -142,14 +111,6 @@ function Course({
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
setNotificationStatus={setNotificationStatus}
|
||||
onNotificationSeen={onNotificationSeen}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
||||
//* * [MM-P2P] Experiment */
|
||||
mmp2p={MMP2P}
|
||||
/>
|
||||
@@ -167,7 +128,7 @@ function Course({
|
||||
<ContentTools course={course} />
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
{ MMP2P.meta.modalLock && <MMP2PBlockModal options={MMP2P} /> }
|
||||
</>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@ import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
import {
|
||||
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
|
||||
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import Course from './Course';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import Course from './Course';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('./NotificationTray', () => () => <div data-testid="NotificationTray" />);
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
|
||||
@@ -65,8 +64,8 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays first section celebration modal', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } });
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { firstSection: true } });
|
||||
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
@@ -85,8 +84,8 @@ describe('Course', () => {
|
||||
});
|
||||
|
||||
it('displays weekly goal celebration modal', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { celebrations: { weeklyGoal: true } });
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { weeklyGoal: true } });
|
||||
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
@@ -106,11 +105,10 @@ describe('Course', () => {
|
||||
render(<Course {...mockData} />);
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger).toHaveClass('trigger-active');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('border-primary-700');
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger).not.toHaveClass('trigger-active');
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700');
|
||||
});
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
@@ -118,10 +116,10 @@ describe('Course', () => {
|
||||
render(<Course {...mockData} />);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByTestId('NotificationTray')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(screen.queryByTestId('NotificationTray')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
@@ -222,4 +220,94 @@ describe('Course', () => {
|
||||
expect(nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
describe('Sequence alerts display', () => {
|
||||
it('renders banner text alert', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block', { type: 'sequential', banner_text: 'Some random banner text to display.' },
|
||||
)];
|
||||
const sequenceMetadata = [Factory.build(
|
||||
'sequenceMetadata', { banner_text: sequenceBlocks[0].banner_text },
|
||||
{ courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] },
|
||||
)];
|
||||
|
||||
const testStore = await initializeTestStore({ courseMetadata, sequenceBlocks, sequenceMetadata });
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId: courseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('Some random banner text to display.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with passing score', async () => {
|
||||
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
|
||||
const testCourseMetadata = Factory.build('courseMetadata', {
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 1.0,
|
||||
entrance_exam_enabled: true,
|
||||
entrance_exam_id: sectionId,
|
||||
entrance_exam_minimum_score_pct: 0.7,
|
||||
entrance_exam_passed: true,
|
||||
},
|
||||
});
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
const sectionBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
|
||||
});
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders Entrance Exam alert with non-passing score', async () => {
|
||||
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
|
||||
const testCourseMetadata = Factory.build('courseMetadata', {
|
||||
entrance_exam_data: {
|
||||
entrance_exam_current_score: 0.3,
|
||||
entrance_exam_enabled: true,
|
||||
entrance_exam_id: sectionId,
|
||||
entrance_exam_minimum_score_pct: 0.7,
|
||||
entrance_exam_passed: false,
|
||||
},
|
||||
});
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
const sectionBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
|
||||
{ courseId: testCourseMetadata.id },
|
||||
)];
|
||||
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
|
||||
});
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId: testCourseMetadata.id,
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore });
|
||||
await waitFor(() => expect(screen.getByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { screen, render } from '@testing-library/react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useModel, useModels } from '../../generic/model-store';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
|
||||
@@ -106,14 +107,16 @@ describe('CourseBreadcrumbs', () => {
|
||||
],
|
||||
]);
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<CourseBreadcrumbs
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
sectionId="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
|
||||
sequenceId="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
|
||||
isStaff
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
<IntlProvider>
|
||||
<BrowserRouter>
|
||||
<CourseBreadcrumbs
|
||||
courseId="course-v1:edX+DemoX+Demo_Course"
|
||||
sectionId="block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations"
|
||||
sequenceId="block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions"
|
||||
isStaff
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
</IntlProvider>,
|
||||
);
|
||||
it('renders course breadcrumbs as expected', async () => {
|
||||
expect(screen.queryAllByRole('link')).toHaveLength(1);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 2.4rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.notification-dot {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.55rem;
|
||||
border-radius: 50% !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
breakpoints,
|
||||
Icon,
|
||||
IconButton,
|
||||
useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBackIos, Close } from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
|
||||
|
||||
function NotificationTray({
|
||||
intl, toggleNotificationTray, onNotificationSeen, upgradeNotificationCurrentState, setupgradeNotificationCurrentState,
|
||||
}) {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
accessExpiration,
|
||||
contentTypeGatingEnabled,
|
||||
offer,
|
||||
org,
|
||||
timeOffsetMillis,
|
||||
userTimezone,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
|
||||
// After three seconds, update notificationSeen (to hide red dot)
|
||||
useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []);
|
||||
|
||||
return (
|
||||
<section className={classNames('notification-tray-container ml-0 ml-lg-4', { 'no-notification': !verifiedMode && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.notificationTray)}>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div className="mobile-close-container" onClick={() => { toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="mobile-close">{intl.formatMessage(messages.responsiveCloseNotificationTray)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="notification-tray-title">{intl.formatMessage(messages.notificationTitle)}</span>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: (
|
||||
<div className="d-inline-flex close-btn">
|
||||
<IconButton src={Close} size="sm" iconAs={Icon} onClick={() => { toggleNotificationTray(); }} variant="primary" alt={intl.formatMessage(messages.closeNotificationTrigger)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="notification-tray-divider" />
|
||||
<div>{verifiedMode
|
||||
? (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
||||
/>
|
||||
) : <p className="notification-tray-content">{intl.formatMessage(messages.noNotificationsMessage)}</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTray.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func,
|
||||
onNotificationSeen: PropTypes.func,
|
||||
upgradeNotificationCurrentState: PropTypes.string.isRequired,
|
||||
setupgradeNotificationCurrentState: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
NotificationTray.defaultProps = {
|
||||
toggleNotificationTray: null,
|
||||
onNotificationSeen: null,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTray);
|
||||
@@ -1,67 +0,0 @@
|
||||
.notification-tray-container {
|
||||
border: 1px solid $light-400;
|
||||
border-radius: 4px;
|
||||
width: 31rem;
|
||||
vertical-align: top;
|
||||
height: 100%;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'lg')) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
}
|
||||
|
||||
.no-notification {
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.notification-tray-title {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 0 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
float: right;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.notification-tray-divider {
|
||||
height: 0.5rem;
|
||||
background: $gray-100;
|
||||
border-top: 1px solid $light-400;
|
||||
border-bottom: 1px solid $light-400;
|
||||
}
|
||||
|
||||
.notification-tray-content {
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mobile-close-container {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid $light-400;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
svg {
|
||||
top: 0.4rem;
|
||||
left: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-close {
|
||||
font-weight: 500;
|
||||
margin-left: 1.2rem;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { breakpoints } from '@edx/paragon';
|
||||
|
||||
import { fetchCourse } from '../data';
|
||||
import {
|
||||
render, initializeMockApp, screen, fireEvent, waitFor,
|
||||
} from '../../setupTest';
|
||||
import initializeStore from '../../store';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
import NotificationTray from './NotificationTray';
|
||||
|
||||
initializeMockApp();
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
describe('NotificationTray', () => {
|
||||
let mockData;
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const defaultMetadata = Factory.build('courseMetadata');
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
|
||||
function setMetadata(attributes, options) {
|
||||
const courseMetadata = Factory.build('courseMetadata', attributes, options);
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
global.innerWidth = breakpoints.large.minWidth;
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
mockData = {
|
||||
toggleNotificationTray: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
it('renders notification tray and close tray button', async () => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
await fetchAndRender(<NotificationTray {...testData} />);
|
||||
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
const notificationCloseIconButton = screen.getByRole('button', { name: /Close notification tray/i });
|
||||
expect(notificationCloseIconButton).toBeInTheDocument();
|
||||
expect(notificationCloseIconButton).toHaveClass('btn-icon-primary');
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
|
||||
// should not render responsive "Back to course" to close the tray
|
||||
expect(screen.queryByText('Back to course')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(<NotificationTray />);
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
|
||||
expect(UpgradeNotification).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no notifications message if no verified mode', async () => {
|
||||
setMetadata({ verified_mode: null });
|
||||
await fetchAndRender(<NotificationTray />);
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders notification tray with full screen "Back to course" at responsive view', async () => {
|
||||
global.innerWidth = breakpoints.medium.maxWidth;
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
};
|
||||
await fetchAndRender(<NotificationTray {...testData} />);
|
||||
|
||||
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
|
||||
await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(responsiveCloseButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getLocalStorage, setLocalStorage } from '../../data/localStorage';
|
||||
|
||||
import NotificationIcon from './NotificationIcon';
|
||||
import messages from './messages';
|
||||
|
||||
function NotificationTrigger({
|
||||
courseId, intl, toggleNotificationTray, isNotificationTrayVisible, notificationStatus, setNotificationStatus,
|
||||
upgradeNotificationCurrentState,
|
||||
}) {
|
||||
/* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages
|
||||
The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available,
|
||||
compare with the last state they've seen, and if it's different then set dot back to red */
|
||||
function UpdateUpgradeNotificationLastSeen() {
|
||||
if (upgradeNotificationCurrentState) {
|
||||
if (getLocalStorage(`upgradeNotificationLastSeen.${courseId}`) !== upgradeNotificationCurrentState) {
|
||||
setNotificationStatus('active');
|
||||
setLocalStorage(`notificationStatus.${courseId}`, 'active');
|
||||
setLocalStorage(`upgradeNotificationLastSeen.${courseId}`, upgradeNotificationCurrentState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { UpdateUpgradeNotificationLastSeen(); });
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('notification-trigger-btn', { 'trigger-active': isNotificationTrayVisible() })}
|
||||
type="button"
|
||||
onClick={() => { toggleNotificationTray(); }}
|
||||
aria-label={intl.formatMessage(messages.openNotificationTrigger)}
|
||||
>
|
||||
<NotificationIcon status={notificationStatus} notificationColor="bg-danger-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
NotificationTrigger.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func.isRequired,
|
||||
notificationStatus: PropTypes.string.isRequired,
|
||||
setNotificationStatus: PropTypes.func.isRequired,
|
||||
isNotificationTrayVisible: PropTypes.func.isRequired,
|
||||
upgradeNotificationCurrentState: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotificationTrigger);
|
||||
@@ -1,24 +0,0 @@
|
||||
.notification-trigger-btn {
|
||||
border: 1px solid $light-400;
|
||||
background: none;
|
||||
margin-top: 1rem;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, 'sm')) {
|
||||
border: none;
|
||||
margin: 0.3rem 1.25rem 0 0.25rem;
|
||||
top: 0.1rem;
|
||||
right: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-bottom: 2px solid $primary-700;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ClapsMobile from './assets/claps_280x201.gif';
|
||||
import ClapsTablet from './assets/claps_456x328.gif';
|
||||
import messages from './messages';
|
||||
@@ -19,12 +20,13 @@ import { useModel } from '../../../generic/model-store';
|
||||
function CelebrationModal({
|
||||
courseId, intl, isOpen, onClose, ...rest
|
||||
}) {
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org, celebrations } = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
recordFirstSectionCelebration(org, courseId);
|
||||
recordFirstSectionCelebration(org, courseId, celebrations, dispatch);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useModel } from '../../../generic/model-store';
|
||||
function WeeklyGoalCelebrationModal({
|
||||
courseId, daysPerWeek, intl, isOpen, onClose, ...rest
|
||||
}) {
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -57,14 +57,16 @@ function WeeklyGoalCelebrationModal({
|
||||
className="mr-2"
|
||||
style={{ height: '21px', width: '22px' }}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id="learning.celebration.setGoal"
|
||||
defaultMessage="Setting a goal can help you {strongText} in your course."
|
||||
description="It explain the advantages of setting goal"
|
||||
values={{
|
||||
strongText: (<strong>achieve higher performance</strong>),
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="learning.celebration.setGoal"
|
||||
defaultMessage="Setting a goal can help you {strongText} in your course."
|
||||
description="It explain the advantages of setting goal"
|
||||
values={{
|
||||
strongText: (<strong>achieve higher performance</strong>),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</StandardModal>
|
||||
|
||||
@@ -15,9 +15,20 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId) {
|
||||
});
|
||||
}
|
||||
|
||||
function recordFirstSectionCelebration(org, courseId) {
|
||||
function recordFirstSectionCelebration(org, courseId, celebrations, dispatch) {
|
||||
// Tell the LMS
|
||||
postCelebrationComplete(courseId, { first_section: false });
|
||||
// Update our local copy of course data from LMS
|
||||
dispatch(updateModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
celebrations: {
|
||||
...celebrations,
|
||||
firstSection: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Tell our analytics
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
@@ -71,7 +82,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSectio
|
||||
|
||||
// Update our local copy of course data from LMS
|
||||
dispatch(updateModel({
|
||||
modelType: 'coursewareMeta',
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
celebrations: {
|
||||
|
||||
15
src/courseware/course/celebration/utils.test.jsx
Normal file
15
src/courseware/course/celebration/utils.test.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { recordFirstSectionCelebration } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('./data/api');
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(() => ({ administrator: 'admin' })),
|
||||
}));
|
||||
|
||||
describe('recordFirstSectionCelebration', () => {
|
||||
it('updates the local copy of the course data from the LMS', async () => {
|
||||
const dispatchMock = jest.fn();
|
||||
recordFirstSectionCelebration('org', 'courseId', 'celebration', dispatchMock);
|
||||
expect(dispatchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -49,12 +49,10 @@ describe('Notes Visibility', () => {
|
||||
render(<NotesVisibility {...mockData} />);
|
||||
|
||||
const button = screen.getByRole('switch', { name: 'Show Notes' });
|
||||
expect(button)
|
||||
.not.toBeChecked()
|
||||
.toHaveClass('text-success');
|
||||
expect(button.querySelector('svg'))
|
||||
.toHaveClass('fa-pencil-alt')
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
expect(button).not.toBeChecked();
|
||||
expect(button).toHaveClass('text-success');
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-pencil-alt');
|
||||
expect(button.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('shows notes', () => {
|
||||
@@ -63,12 +61,10 @@ describe('Notes Visibility', () => {
|
||||
render(<NotesVisibility {...testData} />);
|
||||
|
||||
const button = screen.getByRole('switch', { name: 'Hide Notes' });
|
||||
expect(button)
|
||||
.toBeChecked()
|
||||
.toHaveClass('text-secondary');
|
||||
expect(button.querySelector('svg'))
|
||||
.toHaveClass('fa-pencil-alt')
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
expect(button).toBeChecked();
|
||||
expect(button).toHaveClass('text-secondary');
|
||||
expect(button.querySelector('svg')).toHaveClass('fa-pencil-alt');
|
||||
expect(button.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('handles click', async () => {
|
||||
@@ -84,9 +80,8 @@ describe('Notes Visibility', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show Notes' }));
|
||||
await waitFor(() => expect(mockFn).toHaveBeenCalled());
|
||||
expect(mockFn)
|
||||
.toHaveBeenCalledTimes(1)
|
||||
.toHaveBeenCalledWith('tools.toggleNotes');
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith('tools.toggleNotes');
|
||||
|
||||
expect(axiosMock.history.put).toHaveLength(1);
|
||||
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { logClick } from './utils';
|
||||
|
||||
function CatalogSuggestion({ intl, variant }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const searchOurCatalogLink = (
|
||||
|
||||
@@ -46,18 +46,22 @@ function CourseCelebration({ intl }) {
|
||||
linkedinAddToProfileUrl,
|
||||
marketingUrl,
|
||||
offer,
|
||||
org,
|
||||
relatedPrograms,
|
||||
title,
|
||||
verifiedMode,
|
||||
verifyIdentityUrl,
|
||||
verificationStatus,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
org,
|
||||
verifiedMode,
|
||||
canViewCertificate,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const {
|
||||
certStatus,
|
||||
certWebViewUrl,
|
||||
downloadUrl,
|
||||
certificateAvailableDate,
|
||||
} = certificateData || {};
|
||||
|
||||
@@ -66,6 +70,7 @@ function CourseCelebration({ intl }) {
|
||||
const dashboardLink = <DashboardLink />;
|
||||
const idVerificationSupportLink = <IdVerificationSupportLink />;
|
||||
const profileLink = <ProfileLink />;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
let buttonPrefix = null;
|
||||
let buttonLocation;
|
||||
@@ -98,9 +103,6 @@ function CourseCelebration({ intl }) {
|
||||
if (certWebViewUrl) {
|
||||
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
|
||||
buttonText = intl.formatMessage(messages.viewCertificateButton);
|
||||
} else if (downloadUrl) {
|
||||
buttonLocation = downloadUrl;
|
||||
buttonText = intl.formatMessage(messages.downloadButton);
|
||||
}
|
||||
if (linkedinAddToProfileUrl) {
|
||||
buttonPrefix = (
|
||||
@@ -127,9 +129,9 @@ function CourseCelebration({ intl }) {
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate"
|
||||
defaultMessage="This course ended on {endDate} and final grades and certificates are scheduled to be
|
||||
available after {certAvailableDate}."
|
||||
id="courseCelebration.certificateBody.notAvailable.endDate.v2"
|
||||
defaultMessage="This course ends on {endDate}. Final grades and any earned certificates are
|
||||
scheduled to be available after {certAvailableDate}."
|
||||
values={{ endDate, certAvailableDate }}
|
||||
description="This shown for leaner when they are eligible for certifcate but it't not available yet, it could because leaners just finished the course quickly!"
|
||||
/>
|
||||
@@ -245,6 +247,29 @@ function CourseCelebration({ intl }) {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (!canViewCertificate) {
|
||||
// We reuse the cert event here. Since this default state is so
|
||||
// Similar to the earned_not_available state, this event name should be fine
|
||||
// to cover the same cases.
|
||||
visitEvent = 'celebration_with_unavailable_cert';
|
||||
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
|
||||
const endDate = intl.formatDate(end, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
...timezoneFormatArgs,
|
||||
});
|
||||
message = (
|
||||
<>
|
||||
<p>
|
||||
{intl.formatMessage(messages.certificateNotAvailableEndDateBody, { endDate })}
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(messages.certificateNotAvailableBodyAccessCert)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,16 +24,21 @@ function CourseExit({ intl }) {
|
||||
enrollmentMode,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
isMasquerading,
|
||||
userHasPassingGrade,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
|
||||
const {
|
||||
isMasquerading,
|
||||
canViewCertificate,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const mode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
canViewCertificate,
|
||||
);
|
||||
|
||||
// Audit users cannot fully complete a course, so we will
|
||||
|
||||
@@ -24,32 +24,41 @@ jest.mock('@edx/frontend-platform/analytics');
|
||||
describe('Course Exit Pages', () => {
|
||||
let axiosMock;
|
||||
let store;
|
||||
const defaultMetadata = Factory.build('courseMetadata', {
|
||||
const coursewareMetadata = Factory.build('courseMetadata', {
|
||||
user_has_passing_grade: true,
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
});
|
||||
const { courseBlocks: defaultCourseBlocks } = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name);
|
||||
const courseId = coursewareMetadata.id;
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata');
|
||||
const { courseBlocks: defaultCourseBlocks } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title);
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
let coursewareMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
||||
coursewareMetadataUrl = appendBrowserTimezoneToUrl(coursewareMetadataUrl);
|
||||
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
|
||||
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
|
||||
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
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) {
|
||||
const courseMetadata = { ...defaultMetadata, ...attributes };
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
function setMetadata(coursewareAttributes, courseHomeAttributes = {}) {
|
||||
const extendedCourseMetadata = { ...coursewareMetadata, ...coursewareAttributes };
|
||||
axiosMock.onGet(coursewareMetadataUrl).reply(200, extendedCourseMetadata);
|
||||
const extendedCourseHomeMetadata = { ...courseHomeMetadata, ...courseHomeAttributes };
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, extendedCourseHomeMetadata);
|
||||
}
|
||||
|
||||
async function fetchAndRender(component) {
|
||||
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
|
||||
await executeThunk(fetchCourse(courseId), store.dispatch);
|
||||
render(component, { store });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(coursewareMetadataUrl).reply(200, coursewareMetadata);
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||||
Factory.build('courseRecommendations', {}, { numRecs: 2 }));
|
||||
axiosMock.onGet(enrollmentsUrl).reply(200, []);
|
||||
@@ -94,22 +103,11 @@ describe('Course Exit Pages', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Celebration Experience', () => {
|
||||
it('Displays download link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
cert_status: 'downloadable',
|
||||
download_url: 'fake.download.url',
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays webview link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: {
|
||||
@@ -130,7 +128,7 @@ describe('Course Exit Pages', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your grade and certificate will be ready soon!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your grade and certificate status will be available soon.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays request certificate link', async () => {
|
||||
@@ -160,7 +158,7 @@ describe('Course Exit Pages', () => {
|
||||
it('Displays verify identity link', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${defaultMetadata.id}/`,
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${courseId}/`,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByRole('link', { name: 'Verify ID now' })).toBeInTheDocument();
|
||||
@@ -171,7 +169,7 @@ describe('Course Exit Pages', () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'unverified' },
|
||||
verification_status: 'pending',
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${defaultMetadata.id}/`,
|
||||
verify_identity_url: `${getConfig().LMS_BASE_URL}/verify_student/verify-now/${courseId}/`,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your ID verification is pending and your certificate will be available once approved.')).toBeInTheDocument();
|
||||
@@ -182,6 +180,8 @@ describe('Course Exit Pages', () => {
|
||||
it('Displays upgrade link when available', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
},
|
||||
{
|
||||
verified_mode: {
|
||||
access_expiration_date: '9999-08-06T12:00:00Z',
|
||||
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||
@@ -202,6 +202,8 @@ describe('Course Exit Pages', () => {
|
||||
it('Displays nothing if audit only', async () => {
|
||||
setMetadata({
|
||||
certificate_data: { cert_status: 'audit_passing' },
|
||||
},
|
||||
{
|
||||
verified_mode: null,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
@@ -353,6 +355,65 @@ describe('Course Exit Pages', () => {
|
||||
expect(screen.queryByText('Same Course')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
|
||||
setMetadata({
|
||||
is_enrolled: true,
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: false,
|
||||
certificate_data: undefined,
|
||||
}, {
|
||||
can_view_certificate: false,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
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({
|
||||
is_enrolled: true,
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: true,
|
||||
certificate_data: undefined,
|
||||
}, {
|
||||
can_view_certificate: false,
|
||||
});
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
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({
|
||||
is_enrolled: true,
|
||||
end: tomorrow.toISOString(),
|
||||
user_has_passing_grade: true,
|
||||
certificate_data: {
|
||||
cert_status: 'earned_but_not_available',
|
||||
certificate_available_date: overmorrow.toISOString(),
|
||||
},
|
||||
}, {
|
||||
can_view_certificate: false,
|
||||
});
|
||||
|
||||
await fetchAndRender(<CourseCelebration />);
|
||||
expect(screen.getByText('Your grade and certificate status will be available soon.'));
|
||||
expect(screen.getByText(
|
||||
overmorrow.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
{ exact: false },
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Non-passing Experience', () => {
|
||||
@@ -367,7 +428,7 @@ describe('Course Exit Pages', () => {
|
||||
describe('Course in progress experience', () => {
|
||||
it('Displays link to dates tab', async () => {
|
||||
setMetadata({ user_has_passing_grade: false });
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name,
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(courseId, courseHomeMetadata.title,
|
||||
{ hasScheduledContent: true });
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
|
||||
@@ -394,7 +455,7 @@ describe('Course Exit Pages', () => {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/save_course_goal`;
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post[0].url).toMatch(url);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${defaultMetadata.id}","subscribed_to_reminders":false}`);
|
||||
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","subscribed_to_reminders":false}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ import { logClick, logVisit } from './utils';
|
||||
|
||||
function CourseInProgress({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org, tabs, title } = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
org,
|
||||
tabs,
|
||||
title,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
// Get dates tab link for 'view course schedule' button
|
||||
|
||||
@@ -16,7 +16,11 @@ import { logClick, logVisit } from './utils';
|
||||
|
||||
function CourseNonPassing({ intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org, tabs, title } = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
org,
|
||||
tabs,
|
||||
title,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
// Get progress tab link for 'view grades' button
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import truncate from 'truncate-html';
|
||||
import { useModel } from '../../../generic/model-store/hooks';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import fetchCourseRecommendations from './data/thunks';
|
||||
import { FAILED, LOADED, LOADING } from './data/slice';
|
||||
import CatalogSuggestion from './CatalogSuggestion';
|
||||
@@ -106,8 +106,8 @@ function CourseCard({
|
||||
<Card.ImageCap src={image.src} />
|
||||
<Card.Header title={truncate(title, 70, { reserveLastWord: -1 })} subtitle={subtitle} size="sm" />
|
||||
{/* Section is needed for internal vertical spacing to work out. If you can remove, be my guest */}
|
||||
<Card.Section />
|
||||
<Card.Footer textElement={intl.formatMessage(messages.recommendationsCourseFooter)} />
|
||||
<Card.Section> <></> </Card.Section>
|
||||
<Card.Footer textElement={intl.formatMessage(messages.recommendationsCourseFooter)}><></></Card.Footer>
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
</div>
|
||||
@@ -133,7 +133,8 @@ const IntlCard = injectIntl(CourseCard);
|
||||
|
||||
function CourseRecommendations({ intl, variant }) {
|
||||
const { courseId, recommendationsStatus } = useSelector(state => ({ ...state.recommendations, ...state.courseware }));
|
||||
const { org, number, recommendations } = useModel('coursewareMeta', courseId);
|
||||
const { recommendations } = useModel('coursewareMeta', courseId);
|
||||
const { org, number } = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const courseKey = `${org}+${number}`;
|
||||
@@ -143,15 +144,17 @@ function CourseRecommendations({ intl, variant }) {
|
||||
dispatch(fetchCourseRecommendations(courseKey, courseId));
|
||||
}, [dispatch]);
|
||||
|
||||
const recommendationsLength = recommendations ? recommendations.length : 0;
|
||||
|
||||
if (recommendationsStatus && recommendationsStatus !== LOADING) {
|
||||
sendTrackEvent('edx.ui.lms.course_exit.recommendations.viewed', {
|
||||
course_key: courseKey,
|
||||
recommendations_status: recommendationsStatus,
|
||||
recommendations_length: recommendations ? recommendations.length : 0,
|
||||
recommendations_length: recommendationsLength,
|
||||
});
|
||||
}
|
||||
|
||||
if (recommendationsStatus === FAILED || (recommendationsStatus === LOADED && recommendations.length < 2)) {
|
||||
if (recommendationsStatus === FAILED || (recommendationsStatus === LOADED && recommendationsLength < 2)) {
|
||||
return (<CatalogSuggestion variant={variant} />);
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ function CourseRecommendations({ intl, variant }) {
|
||||
<div className="mb-2 mt-3">
|
||||
<DataTable
|
||||
isPaginated
|
||||
itemCount={recommendations.length}
|
||||
itemCount={recommendationsLength}
|
||||
data={recommendationData}
|
||||
columns={[{ Header: 'Title', accessor: 'title' }]}
|
||||
initialState={{
|
||||
|
||||
@@ -18,7 +18,7 @@ import { logClick } from './utils';
|
||||
|
||||
function DashboardFootnote({ intl, variant }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const dashboardLink = (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useModel } from '../../../generic/model-store';
|
||||
|
||||
function UpgradeFootnote({ deadline, href, intl }) {
|
||||
const { courseId } = useSelector(state => state.courseware);
|
||||
const { org } = useModel('coursewareMeta', courseId);
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
const upgradeLink = (
|
||||
|
||||
@@ -13,7 +13,7 @@ const messages = defineMessages({
|
||||
},
|
||||
certificateHeaderNotAvailable: {
|
||||
id: 'courseCelebration.certificateHeader.notAvailable',
|
||||
defaultMessage: 'Your grade and certificate will be ready soon!',
|
||||
defaultMessage: 'Your grade and certificate status will be available soon.',
|
||||
description: 'Header displayed when course certificate is not yet available to be viewed',
|
||||
},
|
||||
certificateNotAvailableBodyAccessCert: {
|
||||
@@ -21,6 +21,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'If you have earned a passing grade, your certificate will be automatically issued.',
|
||||
description: 'Text displayed when course certificate is not yet available to be viewed',
|
||||
},
|
||||
certificateNotAvailableEndDateBody: {
|
||||
id: 'courseCelebration.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.',
|
||||
},
|
||||
certificateHeaderUnverified: {
|
||||
id: 'courseCelebration.certificateHeader.unverified',
|
||||
defaultMessage: 'You must complete verification to receive your certificate.',
|
||||
@@ -71,11 +76,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Dashboard',
|
||||
description: 'Link to user’s dashboard',
|
||||
},
|
||||
downloadButton: {
|
||||
id: 'courseCelebration.downloadButton',
|
||||
defaultMessage: 'Download my certificate',
|
||||
description: 'Button to download the course certificate',
|
||||
},
|
||||
endOfCourseDescription: {
|
||||
id: 'courseExit.endOfCourseDescription',
|
||||
defaultMessage: 'Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.',
|
||||
|
||||
@@ -31,6 +31,7 @@ function getCourseExitMode(
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive = null,
|
||||
canImmediatelyViewCertificate = false,
|
||||
) {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
@@ -55,7 +56,7 @@ function getCourseExitMode(
|
||||
if (hasScheduledContent && !userHasPassingGrade) {
|
||||
return COURSE_EXIT_MODES.inProgress;
|
||||
}
|
||||
if (isEligibleForCertificate && !userHasPassingGrade) {
|
||||
if (isEligibleForCertificate && !userHasPassingGrade && canImmediatelyViewCertificate) {
|
||||
return COURSE_EXIT_MODES.nonPassing;
|
||||
}
|
||||
if (isCelebratoryStatus) {
|
||||
@@ -73,12 +74,14 @@ function getCourseExitNavigation(courseId, intl) {
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
} = useModel('coursewareMeta', courseId);
|
||||
const { canViewCertificate } = useModel('courseHomeMeta', courseId);
|
||||
const exitMode = getCourseExitMode(
|
||||
certificateData,
|
||||
hasScheduledContent,
|
||||
isEnrolled,
|
||||
userHasPassingGrade,
|
||||
courseExitPageIsActive,
|
||||
canViewCertificate,
|
||||
);
|
||||
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useContext, useState,
|
||||
useEffect, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
@@ -15,16 +15,16 @@ import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../../alerts/sequence-alerts/hooks';
|
||||
|
||||
import CourseLicense from '../course-license';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import SidebarTriggers from '../sidebar/SidebarTriggers';
|
||||
import messages from './messages';
|
||||
import HiddenAfterDue from './hidden-after-due';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import SequenceContent from './SequenceContent';
|
||||
import NotificationTray from '../NotificationTray';
|
||||
import NotificationTrigger from '../NotificationTrigger';
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
import { isMobile } from '../../../experiments/mm-p2p/utils';
|
||||
@@ -38,17 +38,13 @@ function Sequence({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
intl,
|
||||
toggleNotificationTray,
|
||||
notificationTrayVisible,
|
||||
isNotificationTrayVisible,
|
||||
notificationStatus,
|
||||
setNotificationStatus,
|
||||
onNotificationSeen,
|
||||
upgradeNotificationCurrentState,
|
||||
setupgradeNotificationCurrentState,
|
||||
mmp2p,
|
||||
}) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
isStaff,
|
||||
originalUserIsStaff,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
@@ -97,26 +93,20 @@ function Sequence({
|
||||
sendTrackingLogEvent(eventName, payload);
|
||||
};
|
||||
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
useSequenceBannerTextAlert(sequenceId);
|
||||
useSequenceEntranceExamAlert(courseId, sequenceId, intl);
|
||||
|
||||
useEffect(() => {
|
||||
let id = null;
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequence.bannerText) {
|
||||
id = add({
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
function receiveMessage(event) {
|
||||
const { type } = event.data;
|
||||
if (type === 'entranceExam.passed') {
|
||||
// I know this seems (is) intense. It is implemented this way since we need to refetch the underlying
|
||||
// course blocks that were originally hidden because the Entrance Exam was not passed.
|
||||
global.location.reload();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (id) {
|
||||
remove(id);
|
||||
}
|
||||
};
|
||||
}, [sequenceStatus, sequence]);
|
||||
global.addEventListener('message', receiveMessage);
|
||||
}, []);
|
||||
|
||||
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
|
||||
const handleUnitLoaded = () => {
|
||||
@@ -138,11 +128,11 @@ function Sequence({
|
||||
const loading = sequenceStatus === 'loading' || (sequenceStatus === 'failed' && sequenceMightBeUnit);
|
||||
if (loading) {
|
||||
if (!sequenceId) {
|
||||
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
|
||||
return (<div> {intl.formatMessage(messages.noContent)} </div>);
|
||||
}
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -159,8 +149,8 @@ function Sequence({
|
||||
};
|
||||
|
||||
const defaultContent = (
|
||||
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
||||
<div className={classNames('sequence', { 'position-relative': shouldDisplayNotificationTriggerInSequence })} style={{ width: '100%' }}>
|
||||
<div className="sequence-container d-inline-flex flex-row">
|
||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
@@ -183,17 +173,7 @@ function Sequence({
|
||||
}}
|
||||
goToCourseExitPage={() => goToCourseExitPage()}
|
||||
/>
|
||||
|
||||
{shouldDisplayNotificationTriggerInSequence ? (
|
||||
<NotificationTrigger
|
||||
courseId={courseId}
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
isNotificationTrayVisible={isNotificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
setNotificationStatus={setNotificationStatus}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
/>
|
||||
) : null}
|
||||
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
|
||||
|
||||
<div className="unit-container flex-grow-1">
|
||||
<SequenceContent
|
||||
@@ -202,7 +182,6 @@ function Sequence({
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
@@ -223,16 +202,7 @@ function Sequence({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{notificationTrayVisible ? (
|
||||
<NotificationTray
|
||||
toggleNotificationTray={toggleNotificationTray}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
notificationStatus={notificationStatus}
|
||||
onNotificationSeen={onNotificationSeen}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setupgradeNotificationCurrentState}
|
||||
/>
|
||||
) : null }
|
||||
<Sidebar />
|
||||
|
||||
{/** [MM-P2P] Experiment */}
|
||||
{(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && (
|
||||
@@ -249,9 +219,8 @@ function Sequence({
|
||||
<SequenceExamWrapper
|
||||
sequence={sequence}
|
||||
courseId={courseId}
|
||||
isStaff={course.isStaff}
|
||||
originalUserIsStaff={course.originalUserIsStaff}
|
||||
isIntegritySignatureEnabled={course.isIntegritySignatureEnabled}
|
||||
isStaff={isStaff}
|
||||
originalUserIsStaff={originalUserIsStaff}
|
||||
canAccessProctoredExams={course.canAccessProctoredExams}
|
||||
>
|
||||
{defaultContent}
|
||||
@@ -264,7 +233,7 @@ function Sequence({
|
||||
// sequence status 'failed' and any other unexpected sequence status.
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
{intl.formatMessage(messages.loadFailure)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -277,14 +246,6 @@ Sequence.propTypes = {
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
toggleNotificationTray: PropTypes.func,
|
||||
notificationTrayVisible: PropTypes.bool,
|
||||
isNotificationTrayVisible: PropTypes.func,
|
||||
notificationStatus: PropTypes.string.isRequired,
|
||||
setNotificationStatus: PropTypes.func.isRequired,
|
||||
onNotificationSeen: PropTypes.func,
|
||||
upgradeNotificationCurrentState: PropTypes.string.isRequired,
|
||||
setupgradeNotificationCurrentState: PropTypes.func.isRequired,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
@@ -303,11 +264,6 @@ Sequence.propTypes = {
|
||||
Sequence.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
toggleNotificationTray: null,
|
||||
notificationTrayVisible: null,
|
||||
isNotificationTrayVisible: null,
|
||||
onNotificationSeen: null,
|
||||
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: {
|
||||
flyover: { isVisible: false },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { breakpoints } from '@edx/paragon';
|
||||
import {
|
||||
loadUnit, render, screen, fireEvent, waitFor, initializeTestStore,
|
||||
} from '../../../setupTest';
|
||||
import SidebarContext from '../sidebar/SidebarContext';
|
||||
import Sequence from './Sequence';
|
||||
import { fetchSequenceFailure } from '../../data/slice';
|
||||
|
||||
@@ -386,22 +387,25 @@ describe('Sequence', () => {
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
const testData = {
|
||||
...mockData,
|
||||
notificationTrayVisible: true,
|
||||
};
|
||||
render(<Sequence {...testData} />);
|
||||
render(
|
||||
<SidebarContext.Provider
|
||||
value={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }}
|
||||
>
|
||||
<Sequence {...mockData} />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(await screen.findByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click on notification tray close button', async () => {
|
||||
const toggleNotificationTray = jest.fn();
|
||||
const testData = {
|
||||
...mockData,
|
||||
toggleNotificationTray,
|
||||
notificationTrayVisible: true,
|
||||
};
|
||||
render(<Sequence {...testData} />);
|
||||
render(
|
||||
<SidebarContext.Provider
|
||||
value={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }}
|
||||
>
|
||||
<Sequence {...mockData} />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -16,7 +16,6 @@ function SequenceContent({
|
||||
sequenceId,
|
||||
unitId,
|
||||
unitLoadedHandler,
|
||||
notificationTrayVisible,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
@@ -32,7 +31,7 @@ function SequenceContent({
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -50,7 +49,7 @@ function SequenceContent({
|
||||
if (!unitId || !unit) {
|
||||
return (
|
||||
<div>
|
||||
{intl.formatMessage(messages['learn.sequence.no.content'])}
|
||||
{intl.formatMessage(messages.noContent)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +61,6 @@ function SequenceContent({
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={unitLoadedHandler}
|
||||
notificationTrayVisible={notificationTrayVisible}
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p={mmp2p}
|
||||
/>
|
||||
@@ -75,7 +73,6 @@ SequenceContent.propTypes = {
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
unitLoadedHandler: PropTypes.func.isRequired,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('Sequence Content', () => {
|
||||
courseId: courseware.courseId,
|
||||
sequenceId: courseware.sequenceId,
|
||||
unitId: models.sequences[courseware.sequenceId].unitIds[0],
|
||||
unitLoadedHandler: () => {},
|
||||
unitLoadedHandler: () => { },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import React, {
|
||||
Suspense,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { Modal } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { processEvent } from '../../../course-home/data/thunks';
|
||||
import { fetchCourse } from '../../data';
|
||||
/** [MM-P2P] Experiment */
|
||||
import { MMP2PLockPaywall } from '../../../experiments/mm-p2p';
|
||||
import { useEventListener } from '../../../generic/hooks';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { fetchCourse } from '../../data';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import messages from './messages';
|
||||
|
||||
const HonorCode = React.lazy(() => import('./honor-code'));
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
@@ -85,7 +81,6 @@ function Unit({
|
||||
onLoaded,
|
||||
id,
|
||||
intl,
|
||||
notificationTrayVisible,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
@@ -121,46 +116,37 @@ function Unit({
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
// We use this ref so that we can hold a reference to the currently active event listener.
|
||||
const messageEventListenerRef = useRef(null);
|
||||
const receiveMessage = useCallback(({ data }) => {
|
||||
const {
|
||||
type,
|
||||
payload,
|
||||
} = data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
useEventListener('message', receiveMessage);
|
||||
useEffect(() => {
|
||||
sendUrlHashToFrame(document.getElementById('unit-iframe'));
|
||||
function receiveMessage(event) {
|
||||
const { type, payload } = event.data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (event.data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, event.data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}
|
||||
// If we currently have an event listener, remove it.
|
||||
if (messageEventListenerRef.current !== null) {
|
||||
global.removeEventListener('message', messageEventListenerRef.current);
|
||||
messageEventListenerRef.current = null;
|
||||
}
|
||||
// Now add our new receiveMessage handler as the event listener.
|
||||
global.addEventListener('message', receiveMessage);
|
||||
// And then save it to our ref for next time.
|
||||
messageEventListenerRef.current = receiveMessage;
|
||||
// When the component finally unmounts, use the ref to remove the correct handler.
|
||||
return () => global.removeEventListener('message', messageEventListenerRef.current);
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||
<h2 className="sr-only">{intl.formatMessage(messages['learn.header.h2.placeholder'])}</h2>
|
||||
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
@@ -170,11 +156,11 @@ function Unit({
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} notificationTrayVisible={notificationTrayVisible} />
|
||||
<LockPaywall courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{ /** [MM-P2P] Experiment */ }
|
||||
@@ -185,7 +171,7 @@ function Unit({
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.honor.code'])}
|
||||
srMessage={intl.formatMessage(messages.loadingHonorCode)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -195,7 +181,7 @@ function Unit({
|
||||
{ /** [MM-P2P] Experiment (conditional) */ }
|
||||
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && !showError && (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||
/>
|
||||
)}
|
||||
{!mmp2p.meta.blockContent && !shouldDisplayHonorCode && !hasLoaded && showError && (
|
||||
@@ -266,7 +252,6 @@ Unit.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p: PropTypes.shape({
|
||||
state: PropTypes.shape({
|
||||
|
||||
@@ -8,7 +8,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'learn.contentLock.complete.prerequisite': {
|
||||
id: 'learn.contentLock.complete.prerequisite',
|
||||
defaultMessage: "You must complete the prerequisite: '{prereqSectionName}' to access this content.",
|
||||
defaultMessage: "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
|
||||
description: 'Message shown to indicate which prerequisite the student must complete prior to accessing the locked content. {prereqSectionName} is the name of the prerequisite.',
|
||||
},
|
||||
'learn.contentLock.goToSection': {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useModel } from '../../../../generic/model-store';
|
||||
import messages from './messages';
|
||||
|
||||
function HiddenAfterDue({ courseId, intl }) {
|
||||
const { tabs } = useModel('coursewareMeta', courseId);
|
||||
const { tabs } = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const progressTab = tabs.find(tab => tab.slug === 'progress');
|
||||
const progressLink = progressTab && progressTab.url && (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, breakpoints, useWindowSize } from '@edx/paragon';
|
||||
import {
|
||||
Alert, Hyperlink, breakpoints, useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
import { Locked } from '@edx/paragon/icons';
|
||||
import SidebarContext from '../../sidebar/SidebarContext';
|
||||
import messages from './messages';
|
||||
import certificateLocked from '../../../../generic/assets/edX_locked_certificate.png';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
@@ -19,15 +22,19 @@ import {
|
||||
function LockPaywall({
|
||||
intl,
|
||||
courseId,
|
||||
notificationTrayVisible,
|
||||
}) {
|
||||
const { notificationTrayVisible } = useContext(SidebarContext);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
accessExpiration,
|
||||
marketingUrl,
|
||||
offer,
|
||||
org,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
const {
|
||||
org, verifiedMode,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
// the following variables are set and used for resposive layout to work with
|
||||
// whether the NotificationTray is open or not and if there's an offer with longer text
|
||||
const shouldDisplayBulletPointsBelowCertificate = useWindowSize().width <= breakpoints.large.minWidth;
|
||||
@@ -39,6 +46,9 @@ function LockPaywall({
|
||||
&& !notificationTrayVisible;
|
||||
const shouldWrapTextOnButton = useWindowSize().width > breakpoints.extraSmall.minWidth;
|
||||
|
||||
const accessExpirationDate = accessExpiration ? new Date(accessExpiration.expirationDate) : null;
|
||||
const pastExpirationDeadline = accessExpiration ? new Date(Date.now()) > accessExpirationDate : false;
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +68,16 @@ function LockPaywall({
|
||||
});
|
||||
};
|
||||
|
||||
const logClickPastExpiration = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.gated_content.past_expiration.link_clicked', {
|
||||
...eventProperties,
|
||||
linkCategory: 'gated_content',
|
||||
linkName: 'course_details',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert variant="light" aria-live="off" icon={Locked} className="lock-paywall-container">
|
||||
<div className="row">
|
||||
@@ -65,12 +85,18 @@ function LockPaywall({
|
||||
<h4 aria-level="3">
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
|
||||
</h4>
|
||||
{pastExpirationDeadline ? (
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content.pastExpiration'])}
|
||||
<Hyperlink destination={marketingUrl} onClick={logClickPastExpiration} target="_blank">{intl.formatMessage(messages['learn.lockPaywall.courseDetails'])}</Hyperlink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content'])}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 upgrade-intro">
|
||||
{intl.formatMessage(messages['learn.lockPaywall.content'])}
|
||||
</div>
|
||||
|
||||
<div className={classNames('d-flex flex-row', { 'flex-wrap': notificationTrayVisible || shouldDisplayBulletPointsBelowCertificate })}>
|
||||
<div className={classNames('d-inline-flex flex-row', { 'flex-wrap': notificationTrayVisible || shouldDisplayBulletPointsBelowCertificate })}>
|
||||
<div style={{ float: 'left' }} className="mr-3 mb-2">
|
||||
<img
|
||||
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
|
||||
@@ -94,20 +120,24 @@ function LockPaywall({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
classNames('d-md-flex align-items-md-center text-right', {
|
||||
'col-md-5 mx-md-0': notificationTrayVisible, 'col-md-4 mx-md-3 justify-content-center': !notificationTrayVisible && !shouldDisplayGatedContentTwoColumnsHalf, 'col-md-11 justify-content-end': shouldDisplayGatedContentOneColumn && !shouldDisplayGatedContentTwoColumns, 'col-md-6 justify-content-center': shouldDisplayGatedContentTwoColumnsHalf,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
style={{ whiteSpace: shouldWrapTextOnButton ? 'nowrap' : null }}
|
||||
/>
|
||||
</div>
|
||||
{pastExpirationDeadline
|
||||
? null
|
||||
: (
|
||||
<div
|
||||
className={
|
||||
classNames('d-md-flex align-items-md-center text-right', {
|
||||
'col-md-5 mx-md-0': notificationTrayVisible, 'col-md-4 mx-md-3 justify-content-center': !notificationTrayVisible && !shouldDisplayGatedContentTwoColumnsHalf, 'col-md-11 justify-content-end': shouldDisplayGatedContentOneColumn && !shouldDisplayGatedContentTwoColumns, 'col-md-6 justify-content-center': shouldDisplayGatedContentTwoColumnsHalf,
|
||||
})
|
||||
}
|
||||
>
|
||||
<UpgradeButton
|
||||
offer={offer}
|
||||
onClick={logClick}
|
||||
verifiedMode={verifiedMode}
|
||||
style={{ whiteSpace: shouldWrapTextOnButton ? 'nowrap' : null }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
@@ -115,6 +145,5 @@ function LockPaywall({
|
||||
LockPaywall.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
notificationTrayVisible: PropTypes.bool.isRequired,
|
||||
};
|
||||
export default injectIntl(LockPaywall);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.alert-content.lock-paywall-container {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lock-paywall-container svg {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Lock Paywall', () => {
|
||||
currencySymbol,
|
||||
price,
|
||||
upgradeUrl,
|
||||
} = store.getState().models.coursewareMeta[mockData.courseId].verifiedMode;
|
||||
} = store.getState().models.courseHomeMeta[mockData.courseId].verifiedMode;
|
||||
render(<LockPaywall {...mockData} />);
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: `Upgrade for ${currencySymbol}${price}` });
|
||||
@@ -56,7 +56,7 @@ describe('Lock Paywall', () => {
|
||||
const {
|
||||
currencySymbol,
|
||||
price,
|
||||
} = store.getState().models.coursewareMeta[mockData.courseId].verifiedMode;
|
||||
} = store.getState().models.courseHomeMeta[mockData.courseId].verifiedMode;
|
||||
render(<LockPaywall {...mockData} />);
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: `Upgrade for ${currencySymbol}${price}` });
|
||||
@@ -74,10 +74,48 @@ describe('Lock Paywall', () => {
|
||||
});
|
||||
|
||||
it('does not display anything if course does not have verified mode', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', { verified_mode: null });
|
||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||
const { container } = render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { verified_mode: null });
|
||||
const testStore = await initializeTestStore({ courseHomeMetadata, excludeFetchSequence: true }, false);
|
||||
const { container } = render(<LockPaywall {...mockData} courseId={courseHomeMetadata.id} />, { store: testStore });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('displays past expiration message if expiration date has expired', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '1995-02-22T05:00:00Z',
|
||||
},
|
||||
marketing_url: 'https://example.com/course-details',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
expect(screen.getByText('The upgrade deadline for this course passed. To upgrade, enroll in the next available session.')).toBeInTheDocument();
|
||||
expect(screen.getByText('View Course Details'))
|
||||
.toHaveAttribute('href', 'https://example.com/course-details');
|
||||
});
|
||||
|
||||
it('sends analytics event onClick of past expiration course details link', async () => {
|
||||
sendTrackEvent.mockClear();
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
access_expiration: {
|
||||
expiration_date: '1995-02-22T05:00:00Z',
|
||||
},
|
||||
marketing_url: 'https://example.com/course-details',
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||
const courseDetailsLink = await screen.getByText('View Course Details');
|
||||
fireEvent.click(courseDetailsLink);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.ecommerce.gated_content.past_expiration.link_clicked', {
|
||||
org_key: 'edX',
|
||||
courserun_key: mockData.courseId,
|
||||
linkCategory: 'gated_content',
|
||||
linkName: 'course_details',
|
||||
linkType: 'link',
|
||||
pageName: 'in_course',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Upgrade to gain access to locked features like this one and get the most out of your course.',
|
||||
description: 'Message shown to indicate that a piece of content is unavailable to audit track users.',
|
||||
},
|
||||
'learn.lockPaywall.content.pastExpiration': {
|
||||
id: 'learn.lockPaywall.content.pastExpiration',
|
||||
defaultMessage: 'The upgrade deadline for this course passed. To upgrade, enroll in the next available session. ',
|
||||
description: 'Message shown to indicate that a piece of content is unavailable to audit track users in a course where the expiration deadline has passed.',
|
||||
},
|
||||
'learn.lockPaywall.courseDetails': {
|
||||
id: 'learn.lockPaywall.courseDetails',
|
||||
defaultMessage: 'View Course Details',
|
||||
description: 'Link to the course details page for this course with a past expiration date.',
|
||||
},
|
||||
'learn.lockPaywall.example.alt': {
|
||||
id: 'learn.lockPaywall.example.alt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.content.lock': {
|
||||
id: 'learn.loading.content.lock',
|
||||
defaultMessage: 'Loading locked content messaging...',
|
||||
description: 'Message shown when an interface about locked content is being loaded',
|
||||
headerPlaceholder: {
|
||||
id: 'learn.header.h2.placeholder',
|
||||
defaultMessage: 'Level 2 headings may be created by course providers in the future.',
|
||||
description: 'Message spoken by a screenreader indicating that the h2 tag is a placeholder.',
|
||||
},
|
||||
'learn.loading.honor.code': {
|
||||
id: 'learn.loading.honor.codk',
|
||||
defaultMessage: 'Loading honor code messaging...',
|
||||
description: 'Message shown when an interface about the honor code is being loaded',
|
||||
},
|
||||
'learn.loading.learning.sequence': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
'learn.course.load.failure': {
|
||||
loadFailure: {
|
||||
id: 'learn.course.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
'learn.sequence.no.content': {
|
||||
loadingHonorCode: {
|
||||
id: 'learn.loading.honor.codk',
|
||||
defaultMessage: 'Loading honor code messaging...',
|
||||
description: 'Message shown when an interface about the honor code is being loaded',
|
||||
},
|
||||
loadingLockedContent: {
|
||||
id: 'learn.loading.content.lock',
|
||||
defaultMessage: 'Loading locked content messaging...',
|
||||
description: 'Message shown when an interface about locked content is being loaded',
|
||||
},
|
||||
loadingSequence: {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
noContent: {
|
||||
id: 'learn.sequence.no.content',
|
||||
defaultMessage: 'There is no content here.',
|
||||
description: 'Message shown when there is no content to show a user inside a learning sequence.',
|
||||
},
|
||||
'learn.header.h2.placeholder': {
|
||||
id: 'learn.header.h2.placeholder',
|
||||
defaultMessage: 'Level 2 headings may be created by course providers in the future.',
|
||||
description: 'Message spoken by a screenreader indicating that the h2 tag is a placeholder.',
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -69,8 +69,13 @@ UnitButton.defaultProps = {
|
||||
showCompletion: true,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
...state.models.units[props.unitId],
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
if (props.unitId) {
|
||||
return {
|
||||
...state.models.units[props.unitId],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(UnitButton);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user