Compare commits

...

85 Commits

Author SHA1 Message Date
Ben Warzeski
d9f4df7452 hack: local html rendering 2023-04-12 14:25:08 -04:00
Ben Warzeski
c341eb7d22 unit-logging 2023-04-10 11:09:40 -04:00
Zachary Hancock
7317c9424a feat: update special-exams lib (#1098) 2023-04-10 09:46:21 -04:00
alangsto
d897663b73 feat: upgrade special exams version and add required config values (#1097) 2023-04-06 09:55:06 -04:00
Muhammad Adeel Tajamul
2e4eb158f2 feat: added url param to open discussion sidebar (#1092) 2023-04-04 13:33:35 +05:00
Jenkins
35b229bd1b chore(i18n): update translations 2023-04-02 17:09:43 -04:00
Muhammad Adeel Tajamul
4ebd569792 feat: added open/close state of discussion sidebar in local storage (#1086) 2023-03-28 15:39:00 +05:00
lunyachek
52235ebc1c feat: create component to decode params 2023-03-27 14:54:42 -04:00
Jenkins
aa380e8619 chore(i18n): update translations 2023-03-26 17:09:41 -04:00
lunyachek
4cf0c7f4d7 feat: Add border for active tab in course navigation at Live page 2023-03-22 10:36:33 -04:00
alangsto
743650a99e chore: pin frontend lib special exams version (#1088) 2023-03-17 13:35:26 -04:00
Muhammad Adeel Tajamul
39d89bee9e fix: discussion sidebar loads very slow (#1081) 2023-03-13 05:40:23 +05:00
Jenkins
a601e431b2 chore(i18n): update translations 2023-03-12 17:09:40 -04:00
Muhammad Adeel Tajamul
7519bbe28e fix: copy link for discussion sidebar not working in chrome (#1079) 2023-03-10 06:01:24 +05:00
alangsto
4b90dcbfc3 feat: update special exams version (#1080) 2023-03-09 10:45:47 -05:00
Zachary Hancock
54cb52cb6d feat: update special-exams library (#1078) 2023-03-08 15:14:39 -05:00
renovate[bot]
6dbd3f49dd fix(deps): update dependency @edx/paragon to v20.28.4 2023-03-01 11:25:35 +00:00
renovate[bot]
678502bb40 fix(deps): update dependency @edx/brand to v1.2.0 2023-03-01 07:19:50 +00:00
renovate[bot]
bf77fc7ca1 fix(deps): update dependency query-string to v7.1.3 2023-03-01 02:08:11 +00:00
renovate[bot]
421a9a5d2b fix(deps): update dependency @edx/frontend-lib-special-exams to v2.2.1 2023-02-28 22:45:58 +00:00
Feanil Patel
dfe44cae56 build: Updating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:37:18 -05:00
Feanil Patel
a88571dae8 build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:37:18 -05:00
Feanil Patel
a4ea334692 build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 10:37:18 -05:00
Adam Stankiewicz
97a1cb4ffc chore: upgrade @edx/frontend-platform to v3.4.1 (#1071)
* chore: upgrade @edx/frontend-platform to v3.4.0
* chore: upgrade to frontend-platform v3.4.1
2023-02-28 09:18:28 -05:00
Jenkins
5166bfe056 chore(i18n): update translations 2023-02-26 16:09:40 -05:00
Varsha
33e3765b19 build: add exams url to envs (#1066) 2023-02-22 11:40:24 -05:00
Jenkins
a13e7d7389 chore(i18n): update translations 2023-02-19 16:09:38 -05:00
Isaac Lee
a4ea1b54a4 fix: exams with no due date now display exam type (#1064)
* fix: exams with no due date now display exam type
2023-02-16 15:16:19 -05:00
Eugene Dyudyunov
cd430ebb5d fix: first section celebration
Fix the first section celebration modal showing logic.

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

Notes:
- the weekly goal has the same showing logic, but I assume that is
correct behavior so no changes are added for it in this commit.
- showing a celebration modal for the first section completion when
going directly to the first unit of the second section seems to be a bug
(reproduces on Maple too)
2023-02-14 16:54:40 -05:00
Jenkins
630d44a8cc chore(i18n): update translations 2023-02-12 16:09:38 -05:00
Jenkins
894e16ddf0 chore(i18n): update translations 2023-02-05 16:09:37 -05:00
Muhammad Abdullah Waheed
263c486330 chore: Automate Browserslist DB Update (#987)
* feat: added cron github action to auto update brwoserlist DB periodically

* refactor: used a shared script to update broswerslist DB, create PR and automerge it
2023-01-31 17:41:10 +05:00
Bilal Qamar
b3d33667d4 Updated frontend-build to v12 (#962)
* feat: rebase previous frontend-build upgrade

* chore: make welcome message to default to empty
2023-01-30 12:20:07 -05:00
Jenkins
b500546e8d chore(i18n): update translations 2023-01-29 16:09:37 -05:00
leangseu-edx
cb9e0aa52f feat: copy static to dist folder for prod (#1057)
* feat: copy static to dist folder for prod

* chore: update webpack.prod.config.js

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2023-01-25 14:29:00 -05:00
Leangseu Kim
69ff5463b3 fix: update iframe static stylesheet 2023-01-25 10:08:44 -05:00
ihor-romaniuk
3b4561e142 fix: fix alignment in the streak celebration modal 2023-01-24 11:47:43 -05:00
Jenkins
cf3b3a27bc chore(i18n): update translations 2023-01-22 16:09:35 -05:00
Ben Warzeski
3bb7aa06bc Bw/share by unit (#1050)
* fix: disable share feature on component unmount

* fix: share only on set units

* fix: share only on set units

* fix: typo
2023-01-20 11:33:38 -05:00
Ben Warzeski
4cea9e582b Bw/share by unit (#1049)
* fix: disable share feature on component unmount

* fix: share only on set units

* fix: share only on set units
2023-01-20 11:02:37 -05:00
Ben Warzeski
0c74bb5106 fix: disable share feature on component unmount (#1048)
* fix: disable share feature on component unmount

* chore: make sure useEffect run only once

Co-authored-by: Leangseu Kim <lkim@edx.org>
2023-01-20 09:07:18 -05:00
Jansen Kantor
b082f3ed19 feat: remove mmp2p (#1042)
* feat: remove mmp2p experiment folder

* feat: remove AccessExpirationAlertMMP2P

* feat: remove imports references and conditionals for mmp2p
2023-01-19 15:35:01 -05:00
leangseu-edx
5d477cebb2 fix: update social sharing icon and title (#1046)
* fix: update social sharing icon and title

* chore: remove twitter icon
2023-01-18 10:29:09 -05:00
Leangseu Kim
851e49f8fb chore: install react share and implement social share demo 2023-01-17 09:17:40 -05:00
Jansen Kantor
09436dd175 test: each.it indentation (#1043) 2023-01-12 16:00:06 -05:00
Awais Ansari
53c8e01c28 fix: hide discussions sidebar and trigger icon when unit does not exist or enableInContext is false. (#1039)
* fix: removed nonCourseWare topics from topics

* fix: hide discussions sidebar and trigger icon when enableInContext is false
2023-01-09 14:29:42 +05:00
Leangseu Kim
ed2d816bbe fix: upgrade discount space 2023-01-03 12:54:22 -05:00
Awais Ansari
7c067299fb refactor: rename inContext param to inContextSidebar (#1022) 2022-12-30 17:04:33 +05:00
Mehak Nasir
4ee1570bfa fix: sidebar length is fixed (#1023) 2022-12-28 13:34:17 +05:00
Leangseu Kim
91c548847b fix: upgrade paragon to remedy the pipeline fix 2022-12-23 11:15:17 -05:00
renovate[bot]
49440ffb45 chore(deps): update dependency @edx/reactifex to v2.1.1 2022-12-23 11:54:31 +00:00
renovate[bot]
6752447d94 chore(deps): update dependency @edx/browserslist-config to v1.1.1 2022-12-23 05:25:42 +00:00
renovate[bot]
75c6aadb09 fix(deps): update dependency util to v0.12.5 2022-12-23 05:14:37 +00:00
renovate[bot]
9eceb355f6 fix(deps): update dependency reselect to v4.1.7 2022-12-23 05:02:03 +00:00
renovate[bot]
df7786388c fix(deps): update dependency regenerator-runtime to v0.13.11 2022-12-23 04:51:29 +00:00
renovate[bot]
361de31e22 fix(deps): update dependency react-share to v4.4.1 2022-12-23 04:40:17 +00:00
renovate[bot]
9e040ec8f1 fix(deps): update dependency react-redux to v7.2.9 2022-12-22 22:25:46 +00:00
renovate[bot]
8db8aeed71 fix(deps): update dependency classnames to v2.3.2 2022-12-22 22:15:18 +00:00
renovate[bot]
04471e550b fix(deps): update dependency @popperjs/core to v2.11.6 2022-12-22 22:04:21 +00:00
renovate[bot]
925ee97a76 fix(deps): update dependency @edx/frontend-lib-special-exams to v2.1.2 2022-12-22 21:54:00 +00:00
renovate[bot]
65086af173 chore(deps): update dependency @testing-library/jest-dom to v5.16.5 2022-12-22 21:42:53 +00:00
Adam Stankiewicz
33923d9a69 chore: upgrade @edx/frontend-build from v9 -> v12 (#1017)
* chore: ignore eslint issues during frontend-build v9 -> v12 upgrade

* chore: add comment to .eslintrc.js file

* chore: update frontend-build

* chore: update test and remove a few unit tests

Co-authored-by: Leangseu Kim <lkim@edx.org>
2022-12-22 13:44:02 -05:00
leangseu-edx
080d31e934 Revert "fix: update transifex flag for tx cli 1.4.0"
This reverts commit f8a1147571.
2022-12-20 16:06:36 -05:00
Leangseu Kim
f3c80ed39b fix: text in goal modal 2022-12-20 15:03:08 -05:00
Leangseu Kim
1ca4eda08a chore: fix fo format relative time 2022-12-19 11:10:34 -05:00
Jenkins
6193c2d1b3 chore(i18n): update translations 2022-12-18 16:04:31 -05:00
leangseu-edx
f8a1147571 fix: update transifex flag for tx cli 1.4.0 2022-12-14 11:40:24 -05:00
Ghassan Maslamani
edba1600dc fix: fix tabs urls on progress tab
This fix does fixes the url links by getting them from the state
 simliar to how tabs navigation gets them.

 This would allow it work for PUBLIC_PATH is not '/', i.e. in
 tutor.

  This was reported in openedx/build-test-release-wg/issues/222
2022-12-07 17:55:15 +00:00
Jenkins
9a07ad1501 chore(i18n): update translations 2022-12-04 16:04:31 -05:00
Awais Ansari
b343ca7a74 Revert "Revert "fix: remove minHeight from in-context discussion sidebar (#1002)" (#1003)" (#1007)
This reverts commit ba06fd7c98.
2022-12-01 14:17:18 +05:00
Abdullah Waheed
b6d272e99d feat: added new translations in Makefile and updated all the translations 2022-11-30 13:31:18 +00:00
Mubbshar Anwar
0fbb53ae86 fix: update event name (#1004)
update segment event name

VAN-1168
2022-11-23 11:24:20 +05:00
Awais Ansari
ba06fd7c98 Revert "fix: remove minHeight from in-context discussion sidebar (#1002)" (#1003)
This reverts commit 9396fbd9d4.
2022-11-18 18:22:35 +05:00
Awais Ansari
9396fbd9d4 fix: remove minHeight from in-context discussion sidebar (#1002) 2022-11-17 20:59:50 +05:00
Abderraouf Mehdi Bouhali
57d880de70 fix(rtl): mirror new user tour modal background
Mirrors the background image used in the new user tour modal
as it obstructs the readability of the modal title when in RTL
2022-11-07 16:04:17 +00:00
Abderraouf Mehdi Bouhali
bfad5cf684 fix(rtl): use backslash to write fractions (grades) 2022-11-07 16:03:56 +00:00
Jenkins
b0378e1331 chore(i18n): update translations 2022-11-06 16:04:22 -05:00
Andrew Shultz
19d06d60be fix: display onboarding expired after expiration (#997)
Currently expiring soon is displayed 28 days before expiration
and forever afterwards. Adds an actual expired state for after.

Also clarifies the expring soon message which assumed other course,
that was not necessarily true.

Also updates the take action lines when you do not have valid
onboarding to make sure they appear for everything not currently valid
or in process, and updates the submitted process lines to not appear
for expired statuses.
2022-11-03 09:26:42 -04:00
Zachary Hancock
df91fef82e feat: update special exams lib (#992) 2022-10-31 11:49:24 -04:00
Jenkins
7e53ddb685 chore(i18n): update translations 2022-10-30 17:09:24 -04:00
Diana Olarte
be72e36a3a feat: allow runtime configuration (#955)
Allows frontend-app-learning to be configured at
runtime using the LMS's new MFE Configuration API.

Part of openedx/frontend-wg#103
2022-10-27 10:01:43 -04:00
Abderraouf Mehdi Bouhali
fa5cf8f204 fix(rtl): force (%) symbol to follow text direction 2022-10-26 10:31:53 -03:00
Jenkins
759d154e13 chore(i18n): update translations 2022-10-19 05:08:31 -04:00
Abderraouf Mehdi Bouhali
7c4200e9d3 fix(rtl): mirror position of grade rectangles in grade bar (#980)
Translates the rectangles for current and passing
grades when to appear on the right when in RTL.
2022-10-14 12:49:37 -04:00
Kshitij Sobti
e5e73e40ba feat: update discussion sidebar url to allow grouping by subsection (#968)
To enable grouping by subsection in the discussions MFE, this PR updates
the embed URL to the one that supports grouping.

ref: https://github.com/openedx/frontend-app-discussions/pull/281
2022-10-12 17:57:42 +05:00
252 changed files with 8733 additions and 35307 deletions

3
.env
View File

@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''
FAVICON_URL=''
IGNORED_ERROR_REGEX=''
INSIGHTS_BASE_URL=''
@@ -28,6 +29,8 @@ LOGO_WHITE_URL=''
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL=''
SEGMENT_KEY=''

View File

@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -28,6 +29,8 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''

View File

@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=''
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
@@ -28,6 +29,8 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''

View File

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

View File

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

View File

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

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
export TRANSIFEX_RESOURCE=frontend-app-learning
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n

View File

@@ -9,4 +9,6 @@ module.exports = createConfig('jest', {
'src/i18n',
'src/.*\\.exp\\..*',
],
testTimeout: 30000,
testEnvironment: 'jsdom'
});

34206
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,48 +29,50 @@
"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.4",
"@edx/frontend-component-header": "2.4.6",
"@edx/frontend-lib-special-exams": "1.16.3",
"@edx/frontend-platform": "1.15.6",
"@edx/paragon": "19.18.3",
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "2.10.0",
"@edx/frontend-platform": "4.1.0",
"@edx/paragon": "20.28.4",
"@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.18",
"@popperjs/core": "2.11.5",
"@popperjs/core": "2.11.6",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1",
"classnames": "2.3.2",
"core-js": "3.22.2",
"history": "^5.3.0",
"history": "5.3.0",
"html-react-parser": "^3.0.15",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "^7.1.1",
"query-string": "7.1.3",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.8",
"react-redux": "7.2.9",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.0",
"react-share": "4.4.1",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"reselect": "4.1.5",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"truncate-html": "1.0.4",
"util": "0.12.4"
"util": "0.12.5"
},
"devDependencies": {
"@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "9.1.4",
"@edx/reactifex": "2.0.1",
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.4.15",
"@edx/reactifex": "2.1.1",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "27.5.1",

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { WarningFilled } from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import genericMessages from './messages';
function ActiveEnterpriseAlert({ intl, payload }) {
const ActiveEnterpriseAlert = ({ intl, payload }) => {
const { text, courseId } = payload;
const changeActiveEnterprise = (
<Hyperlink
@@ -35,7 +35,7 @@ function ActiveEnterpriseAlert({ intl, payload }) {
/>
</Alert>
);
}
};
ActiveEnterpriseAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -19,9 +19,7 @@ describe('ActiveEnterpriseAlert', () => {
it('Shows alert message and links', () => {
render(<ActiveEnterpriseAlert {...mockData} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('test message')).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`,
);
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`);
});
});

View File

@@ -12,16 +12,16 @@ export default function useActiveEnterpriseAlert(courseId) {
*/
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
const payload = {
const payload = useMemo(() => ({
text: courseAccess && courseAccess.userMessage,
courseId,
};
}), [courseAccess, courseId]);
useAlert(isVisible, {
code: 'clientActiveEnterpriseAlert',
topic: 'outline',
dismissible: false,
type: ALERT_TYPES.ERROR,
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
});
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };

View File

@@ -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';
@@ -11,9 +11,11 @@ import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store';
const DAY_MS = 24 * 60 * 60 * 1000; // in ms
const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds
function CourseStartAlert({ payload }) {
const CourseStartAlert = ({ payload }) => {
const {
courseId,
} = payload;
@@ -25,15 +27,17 @@ function CourseStartAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const delta = new Date(startDate) - new Date();
const timeRemaining = (
<FormattedRelative
<FormattedRelativeTime
key="timeRemaining"
value={startDate}
value={delta / 1000}
numeric="auto"
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
updateIntervalInSeconds={YEAR_SEC}
{...timezoneFormatArgs}
/>
);
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert variant="info" icon={Info}>
@@ -90,7 +94,7 @@ function CourseStartAlert({ payload }) {
/>
</Alert>
);
}
};
CourseStartAlert.propTypes = {
payload: PropTypes.shape({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -349,7 +349,7 @@ export async function getOutlineTabData(courseId) {
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
const welcomeMessageHtml = data.welcome_message_html || '';
return {
accessExpiration,

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { generatePath, useHistory } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIFrameHeight, useIFramePluginEvents } from '../../generic/hooks';
function DiscussionTab() {
const DiscussionTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const { path } = useParams();
const [originalPath] = useState(path);
@@ -29,7 +29,7 @@ function DiscussionTab() {
title="discussion"
/>
);
}
};
DiscussionTab.propTypes = {};

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
function LiveTab() {
const LiveTab = () => {
const { courseId } = useSelector(state => state.courseHome);
const liveModel = useSelector(state => state.models.live);
useEffect(() => {
@@ -17,6 +17,6 @@ function LiveTab() {
dangerouslySetInnerHTML={{ __html: liveModel[courseId]?.iframe }}
/>
);
}
};
export default LiveTab;

View File

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

View File

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

View File

@@ -29,10 +29,7 @@ import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
/** [MM-P2P] Experiment */
import { initHomeMMP2P, MMP2PFlyover } from '../../experiments/mm-p2p';
function OutlineTab({ intl }) {
const OutlineTab = ({ intl }) => {
const {
courseId,
proctoringPanelStatus,
@@ -104,9 +101,6 @@ function OutlineTab({ intl }) {
return userRoleNames.includes('enterprise_learner');
};
/** [[MM-P2P] Experiment */
const MMP2P = initHomeMMP2P(courseId);
/** show post enrolment survey to only B2C learners */
const learnerType = isEnterpriseUser() ? 'enterprise_learner' : 'b2c_learner';
@@ -116,7 +110,7 @@ function OutlineTab({ intl }) {
const currentParams = new URLSearchParams(location.search);
const startCourse = currentParams.get('start_course');
if (startCourse === '1') {
sendTrackEvent('welcome.email.clicked.startcourse', {});
sendTrackEvent('enrollment.email.clicked.startcourse', {});
// Deleting the course_start query param as it only needs to be set once
// whenever passed in query params.
@@ -134,7 +128,6 @@ function OutlineTab({ intl }) {
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
</div>
{/** [MM-P2P] Experiment (className for optimizely trigger) */}
<div className="row course-outline-tab">
<AccountActivationAlert />
<div className="col-12">
@@ -146,21 +139,17 @@ function OutlineTab({ intl }) {
/>
</div>
<div className="col col-12 col-md-8">
{ /** [MM-P2P] Experiment (the conditional) */ }
{ !MMP2P.state.isEnabled
&& (
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
)}
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
<AlertList
topic="outline-course-alerts"
className="mb-3"
customAlerts={{
...certificateAvailableAlert,
...courseEndAlert,
...courseStartAlert,
...scheduledContentAlert,
}}
/>
{isSelfPaced && hasDeadlines && (
<>
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
@@ -203,35 +192,27 @@ function OutlineTab({ intl }) {
/>
)}
<CourseTools />
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
)}
<CourseDates
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="course_home"
userTimezone={userTimezone}
shouldDisplayBorder
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
/>
<CourseDates />
<CourseHandouts />
</div>
)}
</div>
</>
);
}
};
OutlineTab.propTypes = {
intl: intlShape.isRequired,

View File

@@ -355,13 +355,13 @@ describe('Outline Tab', () => {
await fetchAndRender('http://localhost/?weekly_goal=3');
expect(spy).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.setgoal', {});
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.setgoal', {});
});
it('emit start course event via query param', async () => {
sendTrackEvent.mockClear();
await fetchAndRender('http://localhost/?start_course=1');
expect(sendTrackEvent).toHaveBeenCalledWith('welcome.email.clicked.startcourse', {});
expect(sendTrackEvent).toHaveBeenCalledWith('enrollment.email.clicked.startcourse', {});
});
describe('weekly learning goal is not set', () => {
@@ -383,25 +383,25 @@ describe('Outline Tab', () => {
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeDisabled();
});
it.each`
level | days
${'Casual'} | ${1}
${'Regular'} | ${3}
${'Intense'} | ${5}
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it.each([
{ level: 'Casual', days: 1 },
{ level: 'Regular', days: 3 },
{ level: 'Intense', days: 5 },
])('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
// subscribe is turned on automatically
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","days_per_week":${days},"subscribed_to_reminders":true}`);
// verify that the additional info about subscriptions shows up
expect(screen.queryByText(messages.goalReminderDetail.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByLabelText(messages.setGoalReminder.defaultMessage)).toBeEnabled();
});
it('shows and hides subscribe to reminders additional text', async () => {
const button = await screen.getByTestId('weekly-learning-goal-input-Regular');
fireEvent.click(button);
@@ -577,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 () => {
@@ -789,12 +789,14 @@ describe('Outline Tab', () => {
const requestingButton = screen.getByRole('button', { name: 'Request certificate' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_request_cert_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
},
);
});
it('tracks unverified cert button', async () => {
@@ -833,12 +835,14 @@ describe('Outline Tab', () => {
const requestingButton = screen.getByRole('link', { name: 'Verify my ID' });
fireEvent.click(requestingButton);
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.ui.lms.course_outline.certificate_alert_unverified_button.clicked',
{
courserun_key: courseId,
is_staff: false,
org_key: 'edX',
});
},
);
});
});
@@ -1017,6 +1021,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);
@@ -1028,7 +1048,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();
});

View File

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

View File

@@ -16,13 +16,13 @@ import EffortEstimate from '../../shared/effort-estimate';
import { useModel } from '../../generic/model-store';
import messages from './messages';
function SequenceLink({
const SequenceLink = ({
id,
intl,
courseId,
first,
sequence,
}) {
}) => {
const {
complete,
description,
@@ -39,6 +39,50 @@ function SequenceLink({
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
const displayTitle = showLink ? coursewareUrl : title;
const dueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-set"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
const noDueDateMessage = (
<FormattedMessage
id="learning.outline.sequence-due-date-not-set"
defaultMessage="{description}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
);
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
@@ -70,35 +114,15 @@ function SequenceLink({
<EffortEstimate className="ml-3 align-middle" block={sequence} />
</div>
</div>
{due && (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
<FormattedMessage
id="learning.outline.sequence-due"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
</small>
</div>
)}
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage}
</small>
</div>
</div>
</li>
);
}
};
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,

View File

@@ -25,7 +25,7 @@ export const CERT_STATUS_TYPE = {
UNVERIFIED: 'unverified',
};
function CertificateStatusAlert({ intl, payload }) {
const CertificateStatusAlert = ({ intl, payload }) => {
const dispatch = useDispatch();
const {
certificateAvailableDate,
@@ -189,7 +189,7 @@ function CertificateStatusAlert({ intl, payload }) {
)}
</AlertWrapper>
);
}
};
CertificateStatusAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -75,7 +75,7 @@ function useCertificateStatusAlert(courseId) {
&& hasEnded
&& !userHasPassingGrade
);
const payload = {
const payload = useMemo(() => ({
certificateAvailableDate,
certURL,
certStatus,
@@ -85,11 +85,12 @@ function useCertificateStatusAlert(courseId) {
org,
notPassingCourseEnded,
tabs,
};
}), [certStatus, certURL, certificateAvailableDate, courseId,
endBlock, notPassingCourseEnded, org, tabs, userTimezone]);
useAlert(isVisible || notPassingCourseEnded, {
code: 'clientCertificateStatusAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload,
topic: 'outline-course-alerts',
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. ',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { getProctoringInfoData } from '../../data/api';
import { fetchProctoringInfoResolved } from '../../data/slice';
import { useModel } from '../../../generic/model-store';
function ProctoringInfoPanel({ intl }) {
const ProctoringInfoPanel = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -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));
@@ -112,6 +128,7 @@ function ProctoringInfoPanel({ intl }) {
.finally(() => {
dispatch(fetchProctoringInfoResolved());
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let onboardingExamButton = null;
@@ -154,6 +171,7 @@ function ProctoringInfoPanel({ intl }) {
}
return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{ showInfoPanel && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
@@ -175,17 +193,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">
@@ -196,7 +214,7 @@ function ProctoringInfoPanel({ intl }) {
)}
</>
);
}
};
ProctoringInfoPanel.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

@@ -15,11 +15,11 @@ import { saveWeeklyLearningGoal } from '../../data';
import { useModel } from '../../../generic/model-store';
import './FlagButton.scss';
function WeeklyLearningGoalCard({
const WeeklyLearningGoalCard = ({
daysPerWeek,
subscribedToReminders,
intl,
}) {
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -36,7 +36,7 @@ function WeeklyLearningGoalCard({
const [isGetReminderSelected, setGetReminderSelected] = useState(subscribedToReminders);
const location = useLocation();
function handleSelect(days, triggeredFromEmail = false) {
const 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);
@@ -51,10 +51,10 @@ function WeeklyLearningGoalCard({
reminder_selected: selectReminders,
});
if (triggeredFromEmail) {
sendTrackEvent('welcome.email.clicked.setgoal', {});
sendTrackEvent('enrollment.email.clicked.setgoal', {});
}
}
}
};
function handleSubscribeToReminders(event) {
const isGetReminderChecked = event.target.checked;
@@ -84,6 +84,7 @@ function WeeklyLearningGoalCard({
search: currentParams.toString(),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
return (
@@ -146,7 +147,7 @@ function WeeklyLearningGoalCard({
)}
</Card>
);
}
};
WeeklyLearningGoalCard.propTypes = {
daysPerWeek: PropTypes.number,

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import RelatedLinks from './related-links/RelatedLinks';
import { useModel } from '../../generic/model-store';
function ProgressTab() {
const ProgressTab = () => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -55,6 +55,6 @@ function ProgressTab() {
</div>
</>
);
}
};
export default ProgressTab;

View File

@@ -1237,6 +1237,7 @@ describe('Progress Tab', () => {
month: 'long',
day: 'numeric',
}),
{ exact: false },
)).toBeInTheDocument();
});
});
@@ -1302,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 });
@@ -1315,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();
});
});
@@ -1331,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({
@@ -1343,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();
});
});

View File

@@ -14,7 +14,7 @@ import { DashboardLink, IdVerificationSupportLink, ProfileLink } from '../../../
import { requestCert } from '../../data/thunks';
import messages from './messages';
function CertificateStatus({ intl }) {
const CertificateStatus = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -206,6 +206,7 @@ function CertificateStatus({ intl }) {
grade_variant: gradeEventName,
certificate_status_variant: certEventName,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!certCase) {
@@ -257,7 +258,7 @@ function CertificateStatus({ intl }) {
</Card>
</section>
);
}
};
CertificateStatus.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

@@ -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';
@@ -8,7 +10,7 @@ import IncompleteDonutSegment from './IncompleteDonutSegment';
import LockedDonutSegment from './LockedDonutSegment';
import messages from './messages';
function CompletionDonutChart({ intl }) {
const CompletionDonutChart = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -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)}
@@ -56,7 +60,7 @@ function CompletionDonutChart({ intl }) {
</div>
</>
);
}
};
CompletionDonutChart.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { DashboardLink } from '../../../shared/links';
import messages from './messages';
function CreditInformation({ intl }) {
const CreditInformation = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -106,7 +106,7 @@ function CreditInformation({ intl }) {
{requirements}
</>
);
}
};
CreditInformation.propTypes = {
intl: intlShape.isRequired,

View File

@@ -11,7 +11,7 @@ import CreditInformation from '../../credit-information/CreditInformation';
import messages from '../messages';
function CourseGrade({ intl }) {
const CourseGrade = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -52,7 +52,7 @@ function CourseGrade({ intl }) {
</div>
</section>
);
}
};
CourseGrade.propTypes = {
intl: intlShape.isRequired,

View File

@@ -10,7 +10,7 @@ import { useModel } from '../../../../generic/model-store';
import GradeRangeTooltip from './GradeRangeTooltip';
import messages from '../messages';
function CourseGradeFooter({ intl, passingGrade }) {
const CourseGradeFooter = ({ intl, passingGrade }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -83,7 +83,7 @@ function CourseGradeFooter({ intl, passingGrade }) {
</div>
</div>
);
}
};
CourseGradeFooter.propTypes = {
intl: intlShape.isRequired,

View File

@@ -10,7 +10,7 @@ import { Button, Icon } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
function CourseGradeHeader({ intl }) {
const CourseGradeHeader = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -81,7 +81,7 @@ function CourseGradeHeader({ intl }) {
)}
</div>
);
}
};
CourseGradeHeader.propTypes = {
intl: intlShape.isRequired,

View File

@@ -11,7 +11,7 @@ import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
function CurrentGradeTooltip({ intl, tooltipClassName }) {
const CurrentGradeTooltip = ({ intl, tooltipClassName }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -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>
)}
@@ -62,7 +62,7 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
</text>
</>
);
}
};
CurrentGradeTooltip.defaultProps = {
tooltipClassName: '',

View File

@@ -2,14 +2,16 @@ 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';
import messages from '../messages';
function GradeBar({ intl, passingGrade }) {
const GradeBar = ({ intl, passingGrade }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -26,14 +28,16 @@ function GradeBar({ intl, passingGrade }) {
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" />
@@ -45,7 +49,7 @@ function GradeBar({ intl, passingGrade }) {
</svg>
</div>
);
}
};
GradeBar.propTypes = {
intl: intlShape.isRequired,

View File

@@ -11,7 +11,7 @@ import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
const GradeRangeTooltip = ({ intl, iconButtonClassName, passingGrade }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -72,7 +72,7 @@ function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
/>
</OverlayTrigger>
);
}
};
GradeRangeTooltip.defaultProps = {
iconButtonClassName: '',

View File

@@ -8,7 +8,7 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from '../messages';
function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
const PassingGradeTooltip = ({ intl, passingGrade, tooltipClassName }) => {
const isLocaleRtl = isRtl(getLocale());
let passingGradeDirection = passingGrade < 50 ? '' : '-';
@@ -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>
)}
@@ -47,7 +47,7 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
</text>
</>
);
}
};
PassingGradeTooltip.defaultProps = {
tooltipClassName: '',

View File

@@ -12,13 +12,14 @@ import DetailedGradesTable from './DetailedGradesTable';
import messages from '../messages';
function DetailedGrades({ intl }) {
const DetailedGrades = ({ intl }) => {
const { administrator } = getAuthenticatedUser();
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
tabs,
} = useModel('courseHomeMeta', courseId);
const {
gradesFeatureIsFullyLocked,
@@ -36,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={`/course/${courseId}/home`}
destination={overviewTabUrl}
onClick={logOutlineLinkClick}
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
>
@@ -63,17 +67,19 @@ 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>
);
}
};
DetailedGrades.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,14 +1,16 @@
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';
import messages from '../messages';
import SubsectionTitleCell from './SubsectionTitleCell';
function DetailedGradesTable({ intl }) {
const DetailedGradesTable = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -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 (
@@ -61,7 +64,7 @@ function DetailedGradesTable({ intl }) {
);
})
);
}
};
DetailedGradesTable.propTypes = {
intl: intlShape.isRequired,

View File

@@ -2,24 +2,27 @@ 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 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>
</span>
);
}
};
ProblemScoreDrawer.propTypes = {
intl: intlShape.isRequired,

View File

@@ -14,7 +14,7 @@ import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
import ProblemScoreDrawer from './ProblemScoreDrawer';
function SubsectionTitleCell({ intl, subsection }) {
const SubsectionTitleCell = ({ intl, subsection }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -99,7 +99,7 @@ function SubsectionTitleCell({ intl, subsection }) {
</Collapsible.Body>
</Collapsible.Advanced>
);
}
};
SubsectionTitleCell.propTypes = {
intl: intlShape.isRequired,

View File

@@ -7,9 +7,9 @@ import { Icon } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store';
import messages from '../messages';
function AssignmentTypeCell({
const AssignmentTypeCell = ({
intl, assignmentType, footnoteMarker, footnoteId, locked,
}) {
}) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -42,7 +42,7 @@ function AssignmentTypeCell({
</div>
</div>
);
}
};
AssignmentTypeCell.propTypes = {
intl: intlShape.isRequired,

View File

@@ -7,7 +7,7 @@ import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/
import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
function DroppableAssignmentFootnote({ footnotes, intl }) {
const DroppableAssignmentFootnote = ({ footnotes, intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -37,7 +37,7 @@ function DroppableAssignmentFootnote({ footnotes, intl }) {
</ul>
</>
);
}
};
DroppableAssignmentFootnote.propTypes = {
footnotes: PropTypes.arrayOf(PropTypes.shape({

View File

@@ -5,7 +5,7 @@ import { useModel } from '../../../../generic/model-store';
import GradeSummaryHeader from './GradeSummaryHeader';
import GradeSummaryTable from './GradeSummaryTable';
function GradeSummary() {
const GradeSummary = () => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -28,6 +28,6 @@ function GradeSummary() {
<GradeSummaryTable setAllOfSomeAssignmentTypeIsLocked={setAllOfSomeAssignmentTypeIsLocked} />
</section>
);
}
};
export default GradeSummary;

View File

@@ -11,7 +11,7 @@ import { Blocked, InfoOutline } from '@edx/paragon/icons';
import messages from '../messages';
import { useModel } from '../../../../generic/model-store';
function GradeSummaryHeader({ intl, allOfSomeAssignmentTypeIsLocked }) {
const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -54,7 +54,7 @@ function GradeSummaryHeader({ intl, allOfSomeAssignmentTypeIsLocked }) {
)}
</div>
);
}
};
GradeSummaryHeader.propTypes = {
intl: intlShape.isRequired,

View File

@@ -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';
@@ -12,7 +14,7 @@ import GradeSummaryTableFooter from './GradeSummaryTableFooter';
import messages from '../messages';
function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -66,15 +68,27 @@ 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 },
};
});
const getAssignmentTypeCell = (value) => (
<AssignmentTypeCell
assignmentType={value.type} // eslint-disable-line react/prop-types
footnoteId={value.footnoteId} // eslint-disable-line react/prop-types
footnoteMarker={value.footnoteMarker} // eslint-disable-line react/prop-types
locked={value.locked} // eslint-disable-line react/prop-types
/>
);
const getCell = (locked, value) => <span className={locked ? 'greyed-out' : ''}>{value}</span>;
return (
<>
@@ -85,45 +99,28 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
{
Header: `${intl.formatMessage(messages.assignmentType)}`,
accessor: 'type',
// eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<AssignmentTypeCell
assignmentType={value.type} // eslint-disable-line react/prop-types
footnoteId={value.footnoteId} // eslint-disable-line react/prop-types
footnoteMarker={value.footnoteMarker} // eslint-disable-line react/prop-types
locked={value.locked} // eslint-disable-line react/prop-types
/>
),
Cell: ({ value }) => getAssignmentTypeCell(value),
headerClassName: 'h5 mb-0',
},
{
Header: `${intl.formatMessage(messages.weight)}`,
accessor: 'weight',
headerClassName: 'justify-content-end h5 mb-0',
// eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.weight}</span> // eslint-disable-line react/prop-types
),
Cell: ({ value }) => getCell(value.locked, value.weight),
cellClassName: 'text-right small',
},
{
Header: `${intl.formatMessage(messages.grade)}`,
accessor: 'grade',
headerClassName: 'justify-content-end h5 mb-0',
// eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.grade}</span> // eslint-disable-line react/prop-types
),
Cell: ({ value }) => getCell(value.locked, value.grade),
cellClassName: 'text-right small',
},
{
Header: `${intl.formatMessage(messages.weightedGrade)}`,
accessor: 'weightedGrade',
headerClassName: 'justify-content-end h5 mb-0 text-right',
// eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.weightedGrade}</span> // eslint-disable-line react/prop-types
),
Cell: ({ value }) => getCell(value.locked, value.weightedGrade),
cellClassName: 'text-right font-weight-bold small',
},
]}
@@ -137,7 +134,7 @@ function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
)}
</>
);
}
};
GradeSummaryTable.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,13 +1,15 @@
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';
import messages from '../messages';
function GradeSummaryTableFooter({ intl }) {
const GradeSummaryTableFooter = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -22,15 +24,17 @@ function GradeSummaryTableFooter({ intl }) {
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
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>
);
}
};
GradeSummaryTableFooter.propTypes = {
intl: intlShape.isRequired,

View File

@@ -9,12 +9,13 @@ import { Hyperlink } from '@edx/paragon';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
function RelatedLinks({ intl }) {
const RelatedLinks = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
org,
tabs,
} = useModel('courseHomeMeta', courseId);
const { administrator } = getAuthenticatedUser();
@@ -27,26 +28,35 @@ 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={`/course/${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={`/course/${courseId}/home`} 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>
);
}
};
RelatedLinks.propTypes = {
intl: intlShape.isRequired,

View File

@@ -14,7 +14,7 @@ import { resetDeadlines } from '../data';
import { useModel } from '../../generic/model-store';
import messages from './messages';
function ShiftDatesAlert({ fetch, intl, model }) {
const ShiftDatesAlert = ({ fetch, intl, model }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -29,12 +29,12 @@ function ShiftDatesAlert({ fetch, intl, model }) {
missedGatedContent,
} = datesBannerInfo;
const dispatch = useDispatch();
if (!missedDeadlines || missedGatedContent || hasEnded) {
return null;
}
const dispatch = useDispatch();
return (
<Alert variant="warning">
<Row className="w-100 m-0">
@@ -55,7 +55,7 @@ function ShiftDatesAlert({ fetch, intl, model }) {
</Row>
</Alert>
);
}
};
ShiftDatesAlert.propTypes = {
fetch: PropTypes.func.isRequired,

View File

@@ -3,13 +3,11 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function SuggestedScheduleHeader({ intl }) {
return (
<p className="large">
{intl.formatMessage(messages.suggestedSchedule)}
</p>
);
}
const SuggestedScheduleHeader = ({ intl }) => (
<p className="large">
{intl.formatMessage(messages.suggestedSchedule)}
</p>
);
SuggestedScheduleHeader.propTypes = {
intl: intlShape.isRequired,

View File

@@ -12,7 +12,7 @@ import {
import { useModel } from '../../generic/model-store';
import messages from './messages';
function UpgradeToCompleteAlert({ intl, logUpgradeLinkClick }) {
const UpgradeToCompleteAlert = ({ intl, logUpgradeLinkClick }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -55,7 +55,7 @@ function UpgradeToCompleteAlert({ intl, logUpgradeLinkClick }) {
</Row>
</Alert>
);
}
};
UpgradeToCompleteAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -13,7 +13,7 @@ import {
import { useModel } from '../../generic/model-store';
import messages from './messages';
function UpgradeToShiftDatesAlert({ intl, logUpgradeLinkClick, model }) {
const UpgradeToShiftDatesAlert = ({ intl, logUpgradeLinkClick, model }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -57,7 +57,7 @@ function UpgradeToShiftDatesAlert({ intl, logUpgradeLinkClick, model }) {
</Row>
</Alert>
);
}
};
UpgradeToShiftDatesAlert.propTypes = {
intl: intlShape.isRequired,

View File

@@ -6,30 +6,28 @@ import classNames from 'classnames';
import messages from './messages';
import Tabs from '../generic/tabs/Tabs';
function CourseTabsNavigation({
const CourseTabsNavigation = ({
activeTabSlug, className, tabs, intl,
}) {
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs>
</div>
}) => (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
href={url}
>
{title}
</a>
))}
</Tabs>
</div>
);
}
</div>
);
CourseTabsNavigation.propTypes = {
activeTabSlug: PropTypes.string,

View File

@@ -54,32 +54,32 @@ const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequence
});
// Look at where this is called in componentDidUpdate for more info about its usage
const checkUnitToSequenceUnitRedirect = memoize((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId,
) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then we need to look up the
// correct parent sequence for it, and redirect there.
const unitId = sequenceId; // just for clarity during the rest of this method
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
} else {
const checkUnitToSequenceUnitRedirect = memoize(
(courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) {
if (sequenceMightBeUnit) {
// If the sequence failed to load as a sequence, but it is marked as a possible unit, then
// we need to look up the correct parent sequence for it, and redirect there.
const unitId = sequenceId; // just for clarity during the rest of this method
getSequenceForUnitDeprecated(courseId, unitId).then(
parentId => {
if (parentId) {
history.replace(`/course/${courseId}/${parentId}/${unitId}`);
} else {
history.replace(`/course/${courseId}`);
}
},
() => { // error case
history.replace(`/course/${courseId}`);
}
},
() => { // error case
history.replace(`/course/${courseId}`);
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
history.replace(`/course/${courseId}`);
},
);
} else {
// Invalid sequence that isn't a unit either. Redirect up to main course.
history.replace(`/course/${courseId}`);
}
}
}
});
},
);
// Look at where this is called in componentDidUpdate for more info about its usage
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
@@ -225,9 +225,9 @@ class CoursewareContainer extends Component {
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect(
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId,
);
checkUnitToSequenceUnitRedirect((
courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, sectionViaSequenceId, routeUnitId
));
// Check sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
@@ -255,7 +255,7 @@ class CoursewareContainer extends Component {
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
}
};
handleNextSequenceClick = () => {
const {
@@ -274,14 +274,14 @@ class CoursewareContainer extends Component {
handleNextSectionCelebration(sequenceId, nextSequence.id);
}
}
}
};
handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
history.push(`/course/${courseId}/${previousSequence.id}/last`);
}
}
};
render() {
const {
@@ -320,6 +320,7 @@ const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string),
sectionId: PropTypes.string.isRequired,
saveUnitPosition: PropTypes.any, // eslint-disable-line
});
const sectionShape = PropTypes.shape({

View File

@@ -24,15 +24,13 @@ import { buildOutlineFromBlocks } from './data/__factories__/learningSequencesOu
// to have been passed into the component. Separate tests can handle unit rendering, but this
// proves that the component is rendered and receives the correct props. We probably COULD render
// Unit.jsx and its iframe in this test, but it's already complex enough.
function MockUnit({ courseId, id }) { // eslint-disable-line react/prop-types
return (
<div className="fake-unit">Unit Contents {courseId} {id}</div>
);
}
jest.mock(
'./course/sequence/Unit',
() => MockUnit,
// eslint-disable-next-line react/prop-types
() => function ({ courseId, id }) {
return <div className="fake-unit">Unit Contents {courseId} {id}</div>;
},
);
jest.mock('@edx/frontend-platform/analytics');
@@ -172,7 +170,7 @@ describe('CoursewareContainer', () => {
});
describe('when receiving successful course data', () => {
const courseMetadata = defaultCourseMetadata;
// const courseMetadata = defaultCourseMetadata;
const courseHomeMetadata = defaultCourseHomeMetadata;
const courseId = defaultCourseId;
@@ -250,9 +248,7 @@ describe('CoursewareContainer', () => {
describe('when the URL contains a section ID instead of a sequence ID', () => {
const {
courseBlocks, unitTree, sequenceTree, sectionTree,
} = buildBinaryCourseBlocks(
courseId, courseHomeMetadata.title,
);
} = buildBinaryCourseBlocks(courseId, courseHomeMetadata.title);
function setUrl(urlSequenceId, urlUnitId = null) {
history.push(`/course/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
@@ -268,22 +264,22 @@ describe('CoursewareContainer', () => {
setUpMockRequests({ courseBlocks });
});
describe('when the URL contains a unit ID', () => {
it('should ignore the section ID and redirect based on the unit ID', async () => {
const urlUnit = unitTree[1][1][1];
setUrl(sectionTree[1].id, urlUnit.id);
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
});
// describe('when the URL contains a unit ID', () => {
// it('should ignore the section ID and redirect based on the unit ID', async () => {
// const urlUnit = unitTree[1][1][1];
// setUrl(sectionTree[1].id, urlUnit.id);
// const container = await loadContainer();
// assertLoadedHeader(container);
// assertSequenceNavigation(container, 2);
// assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
// });
it('should ignore invalid unit IDs and redirect to the course root', async () => {
setUrl(sectionTree[1].id, 'foobar');
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
});
});
// it('should ignore invalid unit IDs and redirect to the course root', async () => {
// setUrl(sectionTree[1].id, 'foobar');
// await loadContainer();
// expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
// });
// });
describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => {
@@ -336,26 +332,26 @@ describe('CoursewareContainer', () => {
});
});
describe('when the URL only contains a unit ID', () => {
const { courseBlocks, unitTree, sequenceTree } = buildBinaryCourseBlocks(courseId, courseMetadata.name);
// describe('when the URL only contains a unit ID', () => {
// const { courseBlocks, unitTree, sequenceTree } = buildBinaryCourseBlocks(courseId, courseMetadata.name);
beforeEach(async () => {
setUpMockRequests({ courseBlocks });
});
// beforeEach(async () => {
// setUpMockRequests({ courseBlocks });
// });
it('should insert the sequence ID into the URL', async () => {
const unit = unitTree[1][0][1];
history.push(`/course/${courseId}/${unit.id}`);
const container = await loadContainer();
// it('should insert the sequence ID into the URL', async () => {
// const unit = unitTree[1][0][1];
// history.push(`/course/${courseId}/${unit.id}`);
// const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
const expectedSequenceId = sequenceTree[1][0].id;
const expectedUrl = `http://localhost/course/${courseId}/${expectedSequenceId}/${unit.id}`;
expect(global.location.href).toEqual(expectedUrl);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.id);
});
});
// assertLoadedHeader(container);
// assertSequenceNavigation(container, 2);
// const expectedSequenceId = sequenceTree[1][0].id;
// const expectedUrl = `http://localhost/course/${courseId}/${expectedSequenceId}/${unit.id}`;
// expect(global.location.href).toEqual(expectedUrl);
// expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.id);
// });
// });
describe('when the URL contains a course ID and sequence ID', () => {
const sequenceBlock = defaultSequenceBlock;
@@ -426,20 +422,20 @@ describe('CoursewareContainer', () => {
});
});
describe('when the current sequence is an exam', () => {
const { location } = window;
// describe('when the current sequence is an exam', () => {
// const { location } = window;
beforeEach(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});
// beforeEach(() => {
// delete window.location;
// window.location = {
// assign: jest.fn(),
// };
// });
afterEach(() => {
window.location = location;
});
});
// afterEach(() => {
// window.location = location;
// });
// });
});
describe('when receiving a course_access error_code', () => {

View File

@@ -7,7 +7,9 @@ import { PageRoute } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading';
export default () => {
import DecodePageRoute from '../decode-page-route';
const CoursewareRedirectLandingPage = () => {
const { path } = useRouteMatch();
return (
<div className="flex-grow-1">
@@ -21,7 +23,7 @@ export default () => {
/>
<Switch>
<PageRoute
<DecodePageRoute
path={`${path}/survey/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
@@ -40,7 +42,7 @@ export default () => {
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<PageRoute
<DecodePageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);
@@ -50,3 +52,5 @@ export default () => {
</div>
);
};
export default CoursewareRedirectLandingPage;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
import { render, initializeMockApp } from '../setupTest';
import CoursewareRedirectLandingPage from './CoursewareRedirectLandingPage';
const redirectUrl = jest.fn();
@@ -17,6 +17,7 @@ jest.mock('react-router', () => ({
describe('CoursewareRedirectLandingPage', () => {
beforeEach(async () => {
await initializeMockApp();
delete global.location;
global.location = { assign: redirectUrl };
});

View File

@@ -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';
@@ -18,10 +18,7 @@ import SidebarTriggers from './sidebar/SidebarTriggers';
import { useModel } from '../../generic/model-store';
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
/** [MM-P2P] Experiment */
import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
function Course({
const Course = ({
courseId,
sequenceId,
unitId,
@@ -29,7 +26,7 @@ function Course({
previousSequenceHandler,
unitNavigationHandler,
windowWidth,
}) {
}) => {
const course = useModel('coursewareMeta', courseId);
const {
celebrations,
@@ -46,10 +43,8 @@ function 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(
@@ -71,8 +66,16 @@ function Course({
}
}
/** [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}>
@@ -86,8 +89,6 @@ function Course({
sequenceId={sequenceId}
isStaff={isStaff}
unitId={unitId}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
{shouldDisplayTriggers && (
<SidebarTriggers />
@@ -102,8 +103,6 @@ function Course({
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CelebrationModal
courseId={courseId}
@@ -117,11 +116,9 @@ function Course({
onClose={() => setWeeklyGoalCelebrationOpen(false)}
/>
<ContentTools course={course} />
{ /** [MM-P2P] Experiment */ }
{ MMP2P.meta.modalLock && <MMP2PBlockModal options={MMP2P} /> }
</SidebarProvider>
);
}
};
Course.propTypes = {
courseId: PropTypes.string,
@@ -139,7 +136,7 @@ Course.defaultProps = {
unitId: null,
};
function CourseWrapper(props) {
const CourseWrapper = (props) => {
// useWindowSize initially returns an undefined width intentionally at first.
// See https://www.joshwcomeau.com/react/the-perils-of-rehydration/ for why.
// But <Course> has some tricky window-size-dependent, session-storage-setting logic and React would yell at us if
@@ -151,6 +148,6 @@ function CourseWrapper(props) {
}
return <Course {...props} windowWidth={windowWidth} />;
}
};
export default CourseWrapper;

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