Compare commits

..

117 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
Muhammad Abdullah Waheed
1892edaade refactor: updated renovate config to auto update minor and patch versions of edx dependencies (#963) 2022-09-28 10:47:31 -04:00
Sarina Canelake
381be9a26b Fix github url strings (org edx -> openedx) (#971)
* fix: fix github url strings (org edx -> openedx)

* fix: update path to .github workflows to read from openedx org
2022-09-14 10:01:48 -04:00
Shafqat Farhan
b3841ef446 feat: [VAN-1081] - Set weekly goal and start course events via query param (#972) 2022-09-13 11:00:09 +05:00
Jenkins
5a897e4ea1 chore(i18n): update translations 2022-09-11 17:07:42 -04:00
Hammad Ahmad Waqas
96ceab8b2f fix: Redirecting to MFE home page instead of access-denied page to save redirection (#970) 2022-09-07 15:39:12 +05:00
Hammad Ahmad Waqas
f9806d0759 feat: added support to check if active enterprise is same as EnterpriseCourseEnrollment object (#967) 2022-09-07 11:39:03 +05:00
edx-semantic-release
a7b584c566 chore(i18n): update translations 2022-08-07 17:03:35 -04:00
Shafqat Farhan
193a184142 feat: [VAN-1000] - Set weekly goal through welcome email via query param (#956) 2022-07-26 09:07:55 +05:00
Hammad Ahmad Waqas
3e76f7ac78 feat: Added support to redirect to DSC if required. (#958) 2022-07-25 13:14:41 +05:00
Maman Khan
36062ff3a6 fix: removed derpreciated codecov package (#953) 2022-06-20 08:10:46 +05:00
ruzniaievdm
6257cb4b58 refactor: Replace PDF course certificate view code (#946)
Co-authored-by: ruzniaievdm <ruzniaievdm@gmail.com>
2022-06-13 09:15:09 -04:00
edx-semantic-release
792d9eb758 chore(i18n): update translations 2022-06-12 17:04:27 -04:00
edx-semantic-release
cd84a15891 chore(i18n): update translations 2022-06-03 13:57:12 -04:00
Diana Catalina Olarte
cafb881a61 fix: show site name instead of edX 2022-05-18 12:23:43 +01:00
edX requirements bot
fd94da0a43 feat: Add package-lock file version check (#941) 2022-05-06 15:53:47 +05:00
edx-semantic-release
1e41547b3e chore(i18n): update translations 2022-04-24 17:04:22 -04:00
Renovate Bot
bf2f123367 fix(deps): update dependency @edx/paragon to v19.18.3 2022-04-21 19:06:04 +00:00
Renovate Bot
0211ecf45e fix(deps): update dependency core-js to v3.22.2 2022-04-21 16:49:25 +00:00
renovate[bot]
36ac129267 fix(deps): update dependency @edx/paragon to v19.18.0 (#926)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-04-21 12:32:06 -04:00
renovate[bot]
20d4c35d83 fix(deps): update dependency core-js to v3.22.0 (#927)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-04-21 12:30:10 -04:00
Michael Terry
bbff8e719e fix: remove support for the legacy courseware pages
Access to learners for these pages has been removed, so we don't
need to keep any support for it around. Simplifies some code paths.
2022-04-21 08:50:53 -04:00
edx-semantic-release
5461c08169 chore(i18n): update translations 2022-04-17 17:09:11 -04:00
Michael Terry
ee88a12d8f fix: assume that dates & outline legacy tabs don't exist
They've both been removed from the LMS now. It would be harmless
to keep support for them in place, but it's pointless because
any redirects to the LMS will just come right back to us.

AA-799
2022-04-15 12:26:47 -04:00
Muhammad Adeel Tajamul
9b316bd859 feat: added live tab (#923)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-04-14 10:04:26 +05:00
Renovate Bot
7e7eb83596 fix(deps): update dependency @edx/paragon to v19.15.1 2022-04-13 22:08:11 +00:00
Thomas Tracy
aaa367780d fix: [MICROBA-1769] Cert status before course end (#918)
* fix: [MICROBA-1769] Cert status before course end

Right now, learners who are nonpassing are able to view information
about thier certificates early at the course end screen and progress
pages. This is because we show messaging around the nonpassing state in
some cases before a course ends and certificates are available. This can
also lead to cases where grades are not finalized and students who may
be passing see a scary nonpassing message instead.

This change makes it so during the course exit, a student who finishes a
course before the course is over will see the celebration screen
regardless of passing status. Once the course is over (or if
certificates are available immediately), and they are
still not passing, they will see the nonpassing messaging. The same
change was made for the certificate status alert in the progress tab.
2022-04-13 10:14:00 -04:00
Kshitij Sobti
6d42ee9c6f feat: add discussions tab [BD-38] [TNL-9743] (#879)
* feat: add discussions tab

Adds code to load the discussions MFE in an iframe in the tab so the user isn't redirected to the LMS.

Adds code for the discussions tab, making it dynamically resize based on contents using a postMessage API.

* feat: update path based on user navigation inside discussions MFE

The discussions MFE will send path change events via the postMessage API so that the learning MFE path can be kept in sync. This will allow reloading a page without having the iframe revert to same path each time.
2022-04-13 19:01:29 +05:00
Renovate Bot
41047f4c88 fix(deps): update dependency @edx/paragon to v19.15.0 2022-04-12 09:55:15 -04:00
Renovate Bot
d83551c809 chore(deps): update dependency @testing-library/react to v12.1.5 2022-04-11 23:37:47 +00:00
Renovate Bot
7c3088901d fix(deps): update dependency @edx/frontend-component-footer to v10.2.4 2022-04-11 17:27:50 +00:00
Renovate Bot
518c9ef6c2 chore(deps): update dependency @edx/reactifex to v2 2022-04-11 10:17:28 -04:00
Michael Terry
ae97efaf2b fix: add back es-check & fsevents for now to fix build
A previous commit (7f37575) dropped es-check, which dropped
fsevents, which caused our build system (which is still using
npm@6) to fail with an error like `Unsupported platform for
fsevents` when trying to install fsevents through a dependency
(e.g. when installing npm aliases).

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

I think a longer-term fix would be to instead upgrade our build
system to node16 / npm6. But this is an easy fix for now to unblock
the builds.
2022-04-11 09:46:10 -04:00
270 changed files with 9818 additions and 36220 deletions

3
.env
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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 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 transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n

View File

@@ -15,7 +15,7 @@ Please tag **@edx/engage-squad** on any PRs or issues. Thanks.
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3 .. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/edx/frontend-app-learning :target: https://codecov.io/gh/edx/frontend-app-learning
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational .. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/edx/frontend-app-account/blob/master/LICENSE :target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
Development Development
----------- -----------
@@ -23,7 +23,7 @@ Development
Start Devstack Start Devstack
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
To use this application, `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it. To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
- Run ``make dev.up.lms`` - Run ``make dev.up.lms``
- Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key. - Visit http://localhost:2000/course/course-v1:edX+DemoX+Demo_Course to view the demo course. You can replace ``course-v1:edX+DemoX+Demo_Course`` with a different course key.
@@ -52,7 +52,7 @@ file (which is git-ignored) that defines where to find your local modules, for i
], ],
}; };
See https://github.com/edx/frontend-build#local-module-configuration-for-webpack for more details. See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
Deployment Deployment
---------- ----------

View File

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

34830
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Frontend learning application.", "description": "Frontend learning application.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/edx/frontend-app-learning.git" "url": "git+https://github.com/openedx/frontend-app-learning.git"
}, },
"browserslist": [ "browserslist": [
"extends @edx/browserslist-config" "extends @edx/browserslist-config"
@@ -21,55 +21,58 @@
}, },
"author": "edX", "author": "edX",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme", "homepage": "https://github.com/openedx/frontend-app-learning#readme",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"bugs": { "bugs": {
"url": "https://github.com/edx/frontend-app-learning/issues" "url": "https://github.com/openedx/frontend-app-learning/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "10.2.2", "@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "2.4.6", "@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "1.16.3", "@edx/frontend-lib-special-exams": "2.10.0",
"@edx/frontend-platform": "1.15.6", "@edx/frontend-platform": "4.1.0",
"@edx/paragon": "19.14.1", "@edx/paragon": "20.28.4",
"@fortawesome/fontawesome-svg-core": "1.3.0", "@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.18", "@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.5", "@popperjs/core": "2.11.6",
"@reduxjs/toolkit": "1.8.1", "@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.1", "classnames": "2.3.2",
"core-js": "3.21.1", "core-js": "3.22.2",
"history": "5.3.0",
"html-react-parser": "^3.0.15",
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0", "lodash.camelcase": "4.3.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
"react-redux": "7.2.8", "react-redux": "7.2.9",
"react-router": "5.2.1", "react-router": "5.2.1",
"react-router-dom": "5.3.0", "react-router-dom": "5.3.0",
"react-share": "4.4.0", "react-share": "4.4.1",
"redux": "4.1.2", "redux": "4.1.2",
"regenerator-runtime": "0.13.9", "regenerator-runtime": "0.13.11",
"reselect": "4.1.5", "reselect": "4.1.7",
"truncate-html": "1.0.4", "truncate-html": "1.0.4",
"util": "0.12.4" "util": "0.12.5"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.0.2", "@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "9.1.4", "@edx/frontend-build": "^12.4.15",
"@edx/reactifex": "1.1.0", "@edx/reactifex": "2.1.1",
"@pact-foundation/pact": "9.17.3", "@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.4", "@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0", "@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0", "axios-mock-adapter": "1.20.0",
"codecov": "3.8.3", "copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1", "es-check": "6.2.1",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "27.5.1", "jest": "27.5.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { import {
FormattedDate, FormattedDate,
FormattedMessage, FormattedMessage,
FormattedRelative, FormattedRelativeTime,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
@@ -11,9 +11,11 @@ import { Info } from '@edx/paragon/icons';
import { useModel } from '../../generic/model-store'; 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 { const {
courseId, courseId,
} = payload; } = payload;
@@ -25,15 +27,17 @@ function CourseStartAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const delta = new Date(startDate) - new Date();
const timeRemaining = ( const timeRemaining = (
<FormattedRelative <FormattedRelativeTime
key="timeRemaining" 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} {...timezoneFormatArgs}
/> />
); );
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) { if (delta < DAY_MS) {
return ( return (
<Alert variant="info" icon={Info}> <Alert variant="info" icon={Info}>
@@ -90,7 +94,7 @@ function CourseStartAlert({ payload }) {
/> />
</Alert> </Alert>
); );
} };
CourseStartAlert.propTypes = { CourseStartAlert.propTypes = {
payload: PropTypes.shape({ payload: PropTypes.shape({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,6 @@ Factory.define('outlineTabData')
cert_status: null, cert_status: null,
cert_web_view_url: null, cert_web_view_url: null,
certificate_available_date: null, certificate_available_date: null,
download_url: null,
}, },
course_goals: { course_goals: {
goal_options: [], goal_options: [],

View File

@@ -21,7 +21,7 @@ Object {
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
@@ -339,7 +339,7 @@ Object {
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,
@@ -411,7 +411,6 @@ Object {
"certStatus": null, "certStatus": null,
"certWebViewUrl": null, "certWebViewUrl": null,
"certificateAvailableDate": null, "certificateAvailableDate": null,
"downloadUrl": null,
}, },
"courseBlocks": Object { "courseBlocks": Object {
"courses": Object { "courses": Object {
@@ -445,7 +444,6 @@ Object {
"effortTime": 15, "effortTime": 15,
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"showLink": true, "showLink": true,
"title": "Title of Sequence", "title": "Title of Sequence",
@@ -537,7 +535,7 @@ Object {
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
"canLoadCourseware": true, "canViewCertificate": true,
"celebrations": null, "celebrations": null,
"courseAccess": Object { "courseAccess": Object {
"additionalContextUserMessage": null, "additionalContextUserMessage": null,

View File

@@ -148,12 +148,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
effortTime: block.effort_time, effortTime: block.effort_time,
icon: block.icon, icon: block.icon,
id: block.id, id: block.id,
legacyWebUrl: block.legacy_web_url, // The presence of a URL for the sequence indicates that we want this sequence to be a clickable
// The presence of an legacy URL for the sequence indicates that we want this // link in the outline (even though we ignore the given url and use an internal <Link> to ourselves).
// sequence to be a clickable link in the outline (even though, if the new showLink: !!block.lms_web_url,
// courseware experience is active, we will ignore `legacyWebUrl` and build a
// link to the MFE ourselves).
showLink: !!block.legacy_web_url,
title: block.display_name, title: block.display_name,
}; };
break; break;
@@ -208,10 +205,6 @@ export async function getDatesTabData(courseId) {
return camelCaseObject(data); return camelCaseObject(data);
} catch (error) { } catch (error) {
const { httpErrorStatus } = error && error.customAttributes; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining // The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now. // courseAccess in the metadata call, so just ignore this status for now.
@@ -297,6 +290,20 @@ export async function getProctoringInfoData(courseId, username) {
} }
} }
export async function getLiveTabIframe(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_live/iframe/${courseId}/`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return {};
}
throw error;
}
}
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) { export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference // Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers // Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
@@ -314,21 +321,9 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
let { tabData } = {}; const requestTime = Date.now();
let requestTime = Date.now(); const tabData = await getAuthenticatedHttpClient().get(url);
let responseTime = requestTime; const responseTime = Date.now();
try {
requestTime = Date.now();
tabData = await getAuthenticatedHttpClient().get(url);
responseTime = Date.now();
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course/`);
return {};
}
throw error;
}
const { const {
data, data,
@@ -354,7 +349,7 @@ export async function getOutlineTabData(courseId) {
const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime); const timeOffsetMillis = getTimeOffsetMillis(headers && headers.date, requestTime, responseTime);
const userHasPassingGrade = data.user_has_passing_grade; const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode); const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html; const welcomeMessageHtml = data.welcome_message_html || '';
return { return {
accessExpiration, accessExpiration,

View File

@@ -58,7 +58,6 @@ describe('Course Home Service', () => {
sku: '8CF08E5', sku: '8CF08E5',
upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, upgrade_url: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}), }),
can_load_courseware: boolean(true),
celebrations: like({ celebrations: like({
first_section: false, first_section: false,
streak_length_to_celebrate: null, streak_length_to_celebrate: null,
@@ -106,7 +105,6 @@ describe('Course Home Service', () => {
sku: '8CF08E5', sku: '8CF08E5',
upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`, upgradeUrl: `${getConfig().ECOMMERCE_BASE_URL}/basket/add/?sku=8CF08E5`,
}, },
canLoadCourseware: true,
celebrations: { celebrations: {
firstSection: false, firstSection: false,
streakLengthToCelebrate: null, streakLengthToCelebrate: null,

View File

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

View File

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

View File

@@ -341,12 +341,12 @@ describe('DatesTab', () => {
it('redirects to the home page when unauthenticated', async () => { it('redirects to the home page when unauthenticated', async () => {
await renderDenied('authentication_required'); await renderDenied('authentication_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
}); });
it('redirects to the home page when unenrolled', async () => { it('redirects to the home page when unenrolled', async () => {
await renderDenied('enrollment_required'); await renderDenied('enrollment_required');
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`); expect(global.location.href).toEqual(`http://localhost/course/${courseMetadata.id}/home`);
}); });
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,17 @@ import PropTypes from 'prop-types';
import { import {
FormattedDate, FormattedDate,
FormattedMessage, FormattedMessage,
FormattedRelative, FormattedRelativeTime,
FormattedTime, FormattedTime,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
import { Info } from '@edx/paragon/icons'; import { Info } from '@edx/paragon/icons';
const DAY_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 { const {
description, description,
endDate, endDate,
@@ -20,16 +22,19 @@ function CourseEndAlert({ payload }) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
let msg;
const delta = new Date(endDate) - new Date();
const timeRemaining = ( const timeRemaining = (
<FormattedRelative <FormattedRelativeTime
key="timeRemaining" 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} {...timezoneFormatArgs}
/> />
); );
let msg;
const delta = new Date(endDate) - new Date();
if (delta < DAY_MS) { if (delta < DAY_MS) {
const courseEndTime = ( const courseEndTime = (
<FormattedTime <FormattedTime
@@ -83,7 +88,7 @@ function CourseEndAlert({ payload }) {
{description} {description}
</Alert> </Alert>
); );
} };
CourseEndAlert.propTypes = { CourseEndAlert.propTypes = {
payload: PropTypes.shape({ payload: PropTypes.shape({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,9 @@ describe('Progress Tab', () => {
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`); const progressUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/progress/*`);
const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`; const masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`;
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const overmorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 2);
function setMetadata(attributes, options) { function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', attributes, options); const courseMetadata = Factory.build('courseHomeMetadata', attributes, options);
@@ -956,49 +959,6 @@ describe('Progress Tab', () => {
}); });
}); });
it('Displays download link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Download my certificate' })).toBeInTheDocument();
});
it('sends events on view of progress tab and on click of downloadable certificate link', async () => {
setTabData({
certificate_data: {
cert_status: 'downloadable',
download_url: 'fake.download.url',
},
user_has_passing_grade: true,
});
await fetchAndRender();
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.course_progress.visited', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
track_variant: 'audit',
grade_variant: 'passing',
certificate_status_variant: 'earned_downloadable',
});
const downloadCertificateLink = screen.getByRole('link', { name: 'Download my certificate' });
fireEvent.click(downloadCertificateLink);
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.course_progress.certificate_status.clicked', {
org_key: 'edX',
courserun_key: courseId,
is_staff: false,
certificate_status_variant: 'earned_downloadable',
});
});
it('Displays webview link', async () => { it('Displays webview link', async () => {
setTabData({ setTabData({
certificate_data: { certificate_data: {
@@ -1220,6 +1180,66 @@ describe('Progress Tab', () => {
await fetchAndRender(); await fetchAndRender();
expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument(); expect(screen.queryByTestId('certificate-status-component')).not.toBeInTheDocument();
}); });
it('Shows not available messaging before certificates are available to nonpassing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows not available messaging before certificates are available to passing learners when theres no certificate data', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: undefined,
});
await fetchAndRender();
expect(screen.getByText(`Final grades and any earned certificates are scheduled to be available after ${tomorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`)).toBeInTheDocument();
});
it('Shows certificate_available_date if learner is passing', async () => {
setMetadata({
can_view_certificate: false,
is_enrolled: true,
});
setTabData({
end: tomorrow.toISOString(),
user_has_passing_grade: true,
certificate_data: {
cert_status: 'earned_but_not_available',
certificate_available_date: overmorrow.toISOString(),
},
});
await fetchAndRender();
expect(screen.getByText('Certificate status'));
expect(screen.getByText(
overmorrow.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
{ exact: false },
)).toBeInTheDocument();
});
}); });
describe('Credit Information', () => { describe('Credit Information', () => {
@@ -1283,7 +1303,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument(); expect(screen.getByText('This learner no longer has access to this course. Their access expired on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2020')).toBeInTheDocument(); expect(screen.getByText('1/1/2020', { exact: false })).toBeInTheDocument();
}); });
it('does not render banner when not masquerading', async () => { it('does not render banner when not masquerading', async () => {
setMetadata({ is_enrolled: true, original_user_is_staff: true }); setMetadata({ is_enrolled: true, original_user_is_staff: true });
@@ -1296,7 +1316,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch); await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument(); expect(screen.queryByText('This learner no longer has access to this course. Their access expired on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2020')).not.toBeInTheDocument(); expect(screen.queryByText('1/1/2020', { exact: false })).not.toBeInTheDocument();
}); });
}); });
@@ -1312,7 +1332,7 @@ describe('Progress Tab', () => {
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument(); expect(screen.getByText('This learner does not yet have access to this course. The course starts on', { exact: false })).toBeInTheDocument();
expect(screen.getByText('1/1/2999')).toBeInTheDocument(); expect(screen.getByText('1/1/2999', { exact: false })).toBeInTheDocument();
}); });
it('does not render banner when not masquerading', async () => { it('does not render banner when not masquerading', async () => {
setMetadata({ setMetadata({
@@ -1324,7 +1344,7 @@ describe('Progress Tab', () => {
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch); await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store })); await act(async () => render(<LoadedTabPage courseId={courseId} activeTabSlug="progress">...</LoadedTabPage>, { store }));
expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument(); expect(screen.queryByText('This learner does not yet have access to this course. The course starts on', { exact: false })).not.toBeInTheDocument();
expect(screen.queryByText('1/1/2999')).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 { requestCert } from '../../data/thunks';
import messages from './messages'; import messages from './messages';
function CertificateStatus({ intl }) { const CertificateStatus = ({ intl }) => {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -22,6 +22,8 @@ function CertificateStatus({ intl }) {
const { const {
isEnrolled, isEnrolled,
org, org,
canViewCertificate,
userTimezone,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
@@ -45,6 +47,8 @@ function CertificateStatus({ intl }) {
hasScheduledContent, hasScheduledContent,
isEnrolled, isEnrolled,
userHasPassingGrade, userHasPassingGrade,
null, // CourseExitPageIsActive
canViewCertificate,
); );
const eventProperties = { const eventProperties = {
@@ -57,12 +61,11 @@ function CertificateStatus({ intl }) {
let certStatus; let certStatus;
let certWebViewUrl; let certWebViewUrl;
let downloadUrl; const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (certificateData) { if (certificateData) {
certStatus = certificateData.certStatus; certStatus = certificateData.certStatus;
certWebViewUrl = certificateData.certWebViewUrl; certWebViewUrl = certificateData.certWebViewUrl;
downloadUrl = certificateData.downloadUrl;
} }
let certCase; let certCase;
@@ -138,15 +141,10 @@ function CertificateStatus({ intl }) {
values={{ dashboardLink, profileLink }} values={{ dashboardLink, profileLink }}
/> />
); );
if (certWebViewUrl) { if (certWebViewUrl) {
certEventName = 'earned_viewable'; certEventName = 'earned_viewable';
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`; buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewableButton); buttonText = intl.formatMessage(messages.viewableButton);
} else if (downloadUrl) {
certEventName = 'earned_downloadable';
buttonLocation = downloadUrl;
buttonText = intl.formatMessage(messages.downloadableButton);
} }
break; break;
@@ -178,10 +176,22 @@ function CertificateStatus({ intl }) {
} }
break; break;
// This code shouldn't be hit but coding defensively since switch expects a default statement
default: default:
certCase = null; // if user completes a course before certificates are available, treat it as notAvailable
certEventName = 'no_certificate_status'; // regardless of passing or nonpassing status
if (!canViewCertificate) {
certCase = 'notAvailable';
endDate = intl.formatDate(end, {
year: 'numeric',
month: 'long',
day: 'numeric',
...timezoneFormatArgs,
});
body = intl.formatMessage(messages.notAvailableEndDateBody, { endDate });
} else {
certCase = null;
certEventName = 'no_certificate_status';
}
break; break;
} }
} }
@@ -196,6 +206,7 @@ function CertificateStatus({ intl }) {
grade_variant: gradeEventName, grade_variant: gradeEventName,
certificate_status_variant: certEventName, certificate_status_variant: certEventName,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
if (!certCase) { if (!certCase) {
@@ -247,7 +258,7 @@ function CertificateStatus({ intl }) {
</Card> </Card>
</section> </section>
); );
} };
CertificateStatus.propTypes = { CertificateStatus.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import { useModel } from '../../../../generic/model-store';
import messages from '../messages'; import messages from '../messages';
function CurrentGradeTooltip({ intl, tooltipClassName }) { const CurrentGradeTooltip = ({ intl, tooltipClassName }) => {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -41,7 +41,7 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
overlay={( overlay={(
<Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}> <Popover id={`${isPassing ? 'passing' : 'non-passing'}-grade-tooltip`} aria-hidden="true" className={tooltipClassName}>
<Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}> <Popover.Content data-testid="currentGradeTooltipContent" className={isPassing ? 'text-white' : 'text-dark-700'}>
{currentGrade.toFixed(0)}% {currentGrade.toFixed(0)}{isLocaleRtl ? '\u200f' : ''}%
</Popover.Content> </Popover.Content>
</Popover> </Popover>
)} )}
@@ -62,7 +62,7 @@ function CurrentGradeTooltip({ intl, tooltipClassName }) {
</text> </text>
</> </>
); );
} };
CurrentGradeTooltip.defaultProps = { CurrentGradeTooltip.defaultProps = {
tooltipClassName: '', tooltipClassName: '',

View File

@@ -2,14 +2,16 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import {
getLocale, injectIntl, intlShape, isRtl,
} from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import CurrentGradeTooltip from './CurrentGradeTooltip'; import CurrentGradeTooltip from './CurrentGradeTooltip';
import PassingGradeTooltip from './PassingGradeTooltip'; import PassingGradeTooltip from './PassingGradeTooltip';
import messages from '../messages'; import messages from '../messages';
function GradeBar({ intl, passingGrade }) { const GradeBar = ({ intl, passingGrade }) => {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -26,14 +28,16 @@ function GradeBar({ intl, passingGrade }) {
const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : ''; const lockedTooltipClassName = gradesFeatureIsFullyLocked ? 'locked-overlay' : '';
const adjustedRtlStyle = (percentOffest) => (isRtl(getLocale()) ? { transform: `translateX(${100 - percentOffest}%)` } : {});
return ( return (
<div className="col-12 col-sm-6 align-self-center p-0"> <div className="col-12 col-sm-6 align-self-center p-0">
<div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div> <div className="sr-only">{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}</div>
<svg width="100%" height="100px" className="grade-bar" aria-hidden="true"> <svg width="100%" height="100px" className="grade-bar" aria-hidden="true">
<g style={{ transform: 'translateY(2.61em)' }}> <g style={{ transform: 'translateY(2.61em)' }}>
<rect className="grade-bar__base" width="100%" /> <rect className="grade-bar__base" width="100%" />
<rect className="grade-bar--passing" width={`${passingGrade}%`} /> <rect className="grade-bar--passing" width={`${passingGrade}%`} style={adjustedRtlStyle(passingGrade)} />
<rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} /> <rect className={`grade-bar--current-${isPassing ? 'passing' : 'non-passing'}`} width={`${currentGrade}%`} style={adjustedRtlStyle(currentGrade)} />
{/* Start divider */} {/* Start divider */}
<rect className="grade-bar__divider" /> <rect className="grade-bar__divider" />
@@ -45,7 +49,7 @@ function GradeBar({ intl, passingGrade }) {
</svg> </svg>
</div> </div>
); );
} };
GradeBar.propTypes = { GradeBar.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

@@ -8,7 +8,7 @@ import { OverlayTrigger, Popover } from '@edx/paragon';
import messages from '../messages'; import messages from '../messages';
function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) { const PassingGradeTooltip = ({ intl, passingGrade, tooltipClassName }) => {
const isLocaleRtl = isRtl(getLocale()); const isLocaleRtl = isRtl(getLocale());
let passingGradeDirection = passingGrade < 50 ? '' : '-'; let passingGradeDirection = passingGrade < 50 ? '' : '-';
@@ -25,7 +25,7 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
overlay={( overlay={(
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true"> <Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
<Popover.Content className="text-white"> <Popover.Content className="text-white">
{passingGrade}% {passingGrade}{isLocaleRtl && '\u200f'}%
</Popover.Content> </Popover.Content>
</Popover> </Popover>
)} )}
@@ -47,7 +47,7 @@ function PassingGradeTooltip({ intl, passingGrade, tooltipClassName }) {
</text> </text>
</> </>
); );
} };
PassingGradeTooltip.defaultProps = { PassingGradeTooltip.defaultProps = {
tooltipClassName: '', tooltipClassName: '',

View File

@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -13,13 +12,14 @@ import DetailedGradesTable from './DetailedGradesTable';
import messages from '../messages'; import messages from '../messages';
function DetailedGrades({ intl }) { const DetailedGrades = ({ intl }) => {
const { administrator } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
const { const {
org, org,
tabs,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const { const {
gradesFeatureIsFullyLocked, gradesFeatureIsFullyLocked,
@@ -37,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 <Hyperlink
variant="muted" variant="muted"
isInline isInline
destination={`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`} destination={overviewTabUrl}
onClick={logOutlineLinkClick} onClick={logOutlineLinkClick}
tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'} tabIndex={gradesFeatureIsFullyLocked ? '-1' : '0'}
> >
@@ -64,17 +67,19 @@ function DetailedGrades({ intl }) {
{!hasSectionScores && ( {!hasSectionScores && (
<p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p> <p className="small">{intl.formatMessage(messages.detailedGradesEmpty)}</p>
)} )}
<p className="x-small m-0"> {overviewTabUrl && (
<FormattedMessage <p className="x-small m-0">
id="progress.ungradedAlert" <FormattedMessage
defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}." id="progress.ungradedAlert"
description="Text that precede link that redirect to course outline page" defaultMessage="For progress on ungraded aspects of the course, view your {outlineLink}."
values={{ outlineLink }} description="Text that precede link that redirect to course outline page"
/> values={{ outlineLink }}
</p> />
</p>
)}
</section> </section>
); );
} };
DetailedGrades.propTypes = { DetailedGrades.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; 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 { DataTable } from '@edx/paragon';
import { useModel } from '../../../../generic/model-store'; import { useModel } from '../../../../generic/model-store';
import messages from '../messages'; import messages from '../messages';
import SubsectionTitleCell from './SubsectionTitleCell'; import SubsectionTitleCell from './SubsectionTitleCell';
function DetailedGradesTable({ intl }) { const DetailedGradesTable = ({ intl }) => {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -17,6 +19,7 @@ function DetailedGradesTable({ intl }) {
sectionScores, sectionScores,
} = useModel('progress', courseId); } = useModel('progress', courseId);
const isLocaleRtl = isRtl(getLocale());
return ( return (
sectionScores.map((chapter) => { sectionScores.map((chapter) => {
const subsectionScores = chapter.subsections.filter( const subsectionScores = chapter.subsections.filter(
@@ -32,7 +35,7 @@ function DetailedGradesTable({ intl }) {
const detailedGradesData = subsectionScores.map((subsection) => ({ const detailedGradesData = subsectionScores.map((subsection) => ({
subsectionTitle: <SubsectionTitleCell subsection={subsection} />, subsectionTitle: <SubsectionTitleCell subsection={subsection} />,
score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}/{subsection.numPointsPossible}</span>, score: <span className={subsection.learnerHasAccess ? '' : 'greyed-out'}>{subsection.numPointsEarned}{isLocaleRtl ? '\\' : '/'}{subsection.numPointsPossible}</span>,
})); }));
return ( return (
@@ -61,7 +64,7 @@ function DetailedGradesTable({ intl }) {
); );
}) })
); );
} };
DetailedGradesTable.propTypes = { DetailedGradesTable.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -2,24 +2,27 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; 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'; import messages from '../messages';
function ProblemScoreDrawer({ intl, problemScores, subsection }) { const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => {
const isLocaleRtl = isRtl(getLocale());
return ( return (
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap"> <span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
<span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span> <span id="problem-score-label" className="col-auto p-0">{intl.formatMessage(messages.problemScoreLabel)}</span>
<div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}> <div className={classNames('col', 'p-0', { 'greyed-out': !subsection.learnerHasAccess })}>
<ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label"> <ul className="list-unstyled row w-100 m-0" aria-labelledby="problem-score-label">
{problemScores.map(problemScore => ( {problemScores.map(problemScore => (
<li className="ml-3">{problemScore.earned}/{problemScore.possible}</li> <li className="ml-3">{problemScore.earned}{isLocaleRtl ? '\\' : '/'}{problemScore.possible}</li>
))} ))}
</ul> </ul>
</div> </div>
</span> </span>
); );
} };
ProblemScoreDrawer.propTypes = { ProblemScoreDrawer.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

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