Compare commits

..

21 Commits

Author SHA1 Message Date
Ihor Romaniuk
5d8dccda85 fix: save scroll position on exit from video xblock fullscreen mode (#1077)
* fix: save scroll position on exit from video xblock fullscreen mode

* fix: update tests
2023-06-21 13:45:52 -04:00
Ghassan Maslamani
9ffc2c0b84 fix: sync LMS_BASE_URL for bookmark API if changed
This change makes it possible to use the latest  LMS_BASE_API
  if it was changed because of dynamic config API, which is the
  default case of tutor.

  This changes closes openedx/wg-build-test-release/issues/270

   Fixes that are simlar to this
  - gradebook openedx/frontend-app-gradebook/pull/290
  - course authoring openedx/frontend-app-course-authoring/pull/389
2023-06-13 14:00:03 +01:00
Sagirov Eugeniy
c7ca1ba57e chore: update frontend-platform version to v4.2.0 2023-05-09 17:35:34 -03:00
Dmytro
d947d598b7 fix: show date on home page (#1052)
Correct display of the number of days before the
start of the course into the Course Home.
2023-05-09 11:17:40 -04:00
Asad Ali
4082b4f90e fix: fix links under contenttools (#1108) 2023-04-27 17:13:56 +05:00
lunyachek
fbaa41a8ae feat: Add border for active tab in course navigation at Live page 2023-03-22 10:37:01 -04:00
ihor-romaniuk
5dab08761c fix: fix alignment in the streak celebration modal 2023-03-01 10:01:18 -05:00
Eugene Dyudyunov
e499280e49 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-15 11:49:04 -05:00
ihor-romaniuk
08c7d2d118 fix: text in goal modal 2023-01-17 10:20:56 -05:00
Ghassan Maslamani
17a102d5cf 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 19:13:41 +00:00
Abderraouf Mehdi Bouhali
77e3b17f03 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-14 19:49:23 +00:00
Abderraouf Mehdi Bouhali
b1d51a0468 fix(rtl): use backslash to write fractions (grades) 2022-11-14 19:49:23 +00:00
Jenkins
13f884fc56 chore(i18n): update translations 2022-11-14 19:49:23 +00:00
Andrew Shultz
a701ea5e15 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-14 19:49:23 +00:00
Zachary Hancock
108fb314f5 feat: update special exams lib (#992) 2022-11-14 19:49:23 +00:00
Jenkins
75d2abe1a0 chore(i18n): update translations 2022-11-14 19:49:23 +00:00
Diana Olarte
d00961d85c 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-11-14 19:49:23 +00:00
Abderraouf Mehdi Bouhali
96e2c88837 fix(rtl): force (%) symbol to follow text direction 2022-11-14 19:49:23 +00:00
Jenkins
14a4fae421 chore(i18n): update translations 2022-11-14 19:49:23 +00:00
Abderraouf Mehdi Bouhali
0d1f01628e 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-11-14 19:49:23 +00:00
Kshitij Sobti
95b285d371 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-11-14 19:49:23 +00:00
244 changed files with 35420 additions and 7654 deletions

3
.env
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

34177
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,50 +29,48 @@
"url": "https://github.com/openedx/frontend-app-learning/issues" "url": "https://github.com/openedx/frontend-app-learning/issues"
}, },
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.6.3", "@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "3.6.4", "@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-lib-special-exams": "2.10.0", "@edx/frontend-lib-special-exams": "^2.16.1",
"@edx/frontend-platform": "4.1.0", "@edx/frontend-platform": "^4.2.0",
"@edx/paragon": "20.28.4", "@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.6", "@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1", "@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2", "classnames": "2.3.1",
"core-js": "3.22.2", "core-js": "3.22.2",
"history": "5.3.0", "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", "query-string": "^7.1.1",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
"react-redux": "7.2.9", "react-redux": "7.2.8",
"react-router": "5.2.1", "react-router": "5.2.1",
"react-router-dom": "5.3.0", "react-router-dom": "5.3.0",
"react-share": "4.4.1", "react-share": "4.4.0",
"redux": "4.1.2", "redux": "4.1.2",
"regenerator-runtime": "0.13.11", "regenerator-runtime": "0.13.9",
"reselect": "4.1.7", "reselect": "4.1.5",
"truncate-html": "1.0.4", "truncate-html": "1.0.4",
"util": "0.12.5" "util": "0.12.4"
}, },
"devDependencies": { "devDependencies": {
"@edx/browserslist-config": "1.1.1", "@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "^12.4.15", "@edx/frontend-build": "9.1.4",
"@edx/reactifex": "2.1.1", "@edx/reactifex": "2.0.1",
"@pact-foundation/pact": "9.17.3", "@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.5", "@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.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",
"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

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

View File

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

View File

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

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 = useMemo(() => ({ const payload = {
accessExpiration, accessExpiration,
courseId, courseId,
org, org,
userTimezone, userTimezone,
analyticsPageName, analyticsPageName,
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientAccessExpirationAlert', code: 'clientAccessExpirationAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic, topic,
}); });
@@ -34,14 +34,14 @@ export function useAccessExpirationMasqueradeBanner(courseId, tab) {
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse; const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
const expirationDate = accessExpiration && accessExpiration.expirationDate; const expirationDate = accessExpiration && accessExpiration.expirationDate;
const payload = useMemo(() => ({ const payload = {
expirationDate, expirationDate,
userTimezone, userTimezone,
}), [expirationDate, userTimezone]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientAccessExpirationMasqueradeBanner', code: 'clientAccessExpirationMasqueradeBanner',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts', topic: 'instructor-toolbar-alerts',
}); });

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ const DAY_SEC = 24 * 60 * 60; // in seconds
const DAY_MS = DAY_SEC * 1000; // in ms const DAY_MS = DAY_SEC * 1000; // in ms
const YEAR_SEC = 365 * DAY_SEC; // in seconds const YEAR_SEC = 365 * DAY_SEC; // in seconds
const CourseStartAlert = ({ payload }) => { function CourseStartAlert({ payload }) {
const { const {
courseId, courseId,
} = payload; } = payload;
@@ -38,6 +38,7 @@ const CourseStartAlert = ({ payload }) => {
{...timezoneFormatArgs} {...timezoneFormatArgs}
/> />
); );
if (delta < DAY_MS) { if (delta < DAY_MS) {
return ( return (
<Alert variant="info" icon={Info}> <Alert variant="info" icon={Info}>
@@ -94,7 +95,7 @@ const 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';
const CourseStartMasqueradeBanner = ({ payload }) => { function CourseStartMasqueradeBanner({ payload }) {
const { const {
courseId, courseId,
} = payload; } = payload;
@@ -33,7 +33,7 @@ const CourseStartMasqueradeBanner = ({ payload }) => {
/> />
</PageBanner> </PageBanner>
); );
}; }
CourseStartMasqueradeBanner.propTypes = { CourseStartMasqueradeBanner.propTypes = {
payload: PropTypes.shape({ payload: PropTypes.shape({

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 = useMemo(() => ({ const payload = {
courseId, courseId,
}), [courseId]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientCourseStartAlert', code: 'clientCourseStartAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts', topic: 'outline-course-alerts',
}); });
@@ -42,15 +42,15 @@ export function useCourseStartMasqueradeBanner(courseId, tab) {
isMasquerading, isMasquerading,
} = useModel('courseHomeMeta', courseId); } = useModel('courseHomeMeta', courseId);
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId); const isVisible = isMasquerading && tab === 'progress' && isStartDateInFuture(courseId);
const payload = useMemo(() => ({ const payload = {
courseId, courseId,
}), [courseId]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientCourseStartMasqueradeBanner', code: 'clientCourseStartMasqueradeBanner',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'instructor-toolbar-alerts', topic: 'instructor-toolbar-alerts',
}); });

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';
const EnrollmentAlert = ({ intl, payload }) => { function EnrollmentAlert({ intl, payload }) {
const { const {
canEnroll, canEnroll,
courseId, courseId,
@@ -55,7 +55,7 @@ const EnrollmentAlert = ({ intl, payload }) => {
</div> </div>
</Alert> </Alert>
); );
}; }
EnrollmentAlert.propTypes = { EnrollmentAlert.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

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 = useMemo(() => ({ const payload = {
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false, canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId, courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '', extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff, isStaff: course && course.isStaff,
}), [course, courseId, outline]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientEnrollmentAlert', code: 'clientEnrollmentAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline', topic: 'outline',
}); });

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';
const AccountActivationAlert = ({ function AccountActivationAlert({
intl, intl,
}) => { }) {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showSpinner, setShowSpinner] = useState(false); const [showSpinner, setShowSpinner] = useState(false);
const [showCheck, setShowCheck] = useState(false); const [showCheck, setShowCheck] = useState(false);
@@ -123,7 +123,7 @@ const AccountActivationAlert = ({
{children()} {children()}
</AlertModal> </AlertModal>
); );
}; }
AccountActivationAlert.propTypes = { AccountActivationAlert.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

@@ -349,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

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

View File

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

View File

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

View File

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

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

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';
const ResultPage = ({ courseTitle, error, intl }) => { function ResultPage({ courseTitle, error, intl }) {
const errorDescription = ( const errorDescription = (
<FormattedMessage <FormattedMessage
id="learning.goals.unsubscribe.errorDescription" id="learning.goals.unsubscribe.errorDescription"
@@ -44,7 +44,7 @@ const ResultPage = ({ courseTitle, error, intl }) => {
</Button> </Button>
</> </>
); );
}; }
ResultPage.defaultProps = { ResultPage.defaultProps = {
courseTitle: null, courseTitle: null,

View File

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

View File

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

View File

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

View File

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

View File

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

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';
const Section = ({ function Section({
courseId, courseId,
defaultOpen, defaultOpen,
expand, expand,
intl, intl,
section, section,
}) => { }) {
const { const {
complete, complete,
sequenceIds, sequenceIds,
@@ -38,7 +38,6 @@ const Section = ({
useEffect(() => { useEffect(() => {
setOpen(defaultOpen); setOpen(defaultOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const sectionTitle = ( const sectionTitle = (
@@ -110,7 +109,7 @@ const Section = ({
</Collapsible> </Collapsible>
</li> </li>
); );
}; }
Section.propTypes = { Section.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,

View File

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

View File

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

View File

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

View File

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

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 = useMemo(() => ({ const payload = {
description: endBlock && endBlock.description, description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date, endDate: endBlock && endBlock.date,
userTimezone, userTimezone,
}), [endBlock, userTimezone]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientCourseEndAlert', code: 'clientCourseEndAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts', topic: 'outline-course-alerts',
}); });

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';
const PrivateCourseAlert = ({ intl, payload }) => { function PrivateCourseAlert({ intl, payload }) {
const { const {
anonymousUser, anonymousUser,
canEnroll, canEnroll,
@@ -100,7 +100,7 @@ const PrivateCourseAlert = ({ intl, payload }) => {
)} )}
</Alert> </Alert>
); );
}; }
PrivateCourseAlert.propTypes = { PrivateCourseAlert.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

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 = useMemo(() => ({ const payload = {
anonymousUser: authenticatedUser === null, anonymousUser: authenticatedUser === null,
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false, canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
courseId, courseId,
}), [authenticatedUser, courseId, outline]); };
useAlert(isVisible, { useAlert(isVisible, {
code: 'clientPrivateCourseAlert', code: 'clientPrivateCourseAlert',
dismissible: false, dismissible: false,
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-private-alerts', topic: 'outline-private-alerts',
type: ALERT_TYPES.WELCOME, type: ALERT_TYPES.WELCOME,
}); });

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';
const ScheduledContentAlert = ({ payload }) => { function ScheduledContentAlert({ payload }) {
const { const {
datesTabLink, datesTabLink,
} = payload; } = payload;
@@ -38,7 +38,7 @@ const ScheduledContentAlert = ({ payload }) => {
</div> </div>
</Alert> </Alert>
); );
}; }
ScheduledContentAlert.propTypes = { ScheduledContentAlert.propTypes = {
payload: PropTypes.shape({ payload: PropTypes.shape({

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 = useMemo(() => ({ const payload = {
datesTabLink, datesTabLink,
}), [datesTabLink]); };
useAlert(hasScheduledContent && isEnrolled, { useAlert(hasScheduledContent && isEnrolled, {
code: 'ScheduledContentAlert', code: 'ScheduledContentAlert',
payload, payload: useMemo(() => payload, Object.values(payload).sort()),
topic: 'outline-course-alerts', topic: 'outline-course-alerts',
}); });

View File

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

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';
const CourseHandouts = ({ intl }) => { function CourseHandouts({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -29,7 +29,7 @@ const CourseHandouts = ({ intl }) => {
/> />
</section> </section>
); );
}; }
CourseHandouts.propTypes = { CourseHandouts.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

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';
const CourseTools = ({ intl }) => { function CourseTools({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -79,7 +79,7 @@ const CourseTools = ({ intl }) => {
</ul> </ul>
</section> </section>
); );
}; }
CourseTools.propTypes = { CourseTools.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

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

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';
const LearningGoalButton = ({ function LearningGoalButton({
level, level,
isSelected, isSelected,
handleSelect, handleSelect,
intl, intl,
}) => { }) {
const buttonDetails = { const buttonDetails = {
casual: { casual: {
daysPerWeek: 1, daysPerWeek: 1,
@@ -47,7 +47,7 @@ const LearningGoalButton = ({
isSelected={isSelected} isSelected={isSelected}
/> />
); );
}; }
LearningGoalButton.propTypes = { LearningGoalButton.propTypes = {
level: PropTypes.string.isRequired, level: PropTypes.string.isRequired,

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';
const ProctoringInfoPanel = ({ intl }) => { function ProctoringInfoPanel({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -128,7 +128,6 @@ const ProctoringInfoPanel = ({ intl }) => {
.finally(() => { .finally(() => {
dispatch(fetchProctoringInfoResolved()); dispatch(fetchProctoringInfoResolved());
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
let onboardingExamButton = null; let onboardingExamButton = null;
@@ -171,7 +170,6 @@ const ProctoringInfoPanel = ({ intl }) => {
} }
return ( return (
// eslint-disable-next-line react/jsx-no-useless-fragment
<> <>
{ showInfoPanel && ( { showInfoPanel && (
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}> <section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass()}`}>
@@ -214,7 +212,7 @@ const 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';
const StartOrResumeCourseCard = ({ intl }) => { function StartOrResumeCourseCard({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -56,11 +56,10 @@ const StartOrResumeCourseCard = ({ intl }) => {
)} )}
/> />
{/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */} {/* Footer is needed for internal vertical spacing to work out. If you can remove, be my guest */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<Card.Footer><></></Card.Footer> <Card.Footer><></></Card.Footer>
</Card> </Card>
); );
}; }
StartOrResumeCourseCard.propTypes = { StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

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';
const CertificateStatus = ({ intl }) => { function CertificateStatus({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -206,7 +206,6 @@ const CertificateStatus = ({ intl }) => {
grade_variant: gradeEventName, grade_variant: gradeEventName,
certificate_status_variant: certEventName, certificate_status_variant: certEventName,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
if (!certCase) { if (!certCase) {
@@ -258,7 +257,7 @@ const CertificateStatus = ({ intl }) => {
</Card> </Card>
</section> </section>
); );
}; }
CertificateStatus.propTypes = { CertificateStatus.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';
const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) => { function CompleteDonutSegment({ completePercentage, intl, lockedPercentage }) {
const [showCompletePopover, setShowCompletePopover] = useState(false);
if (!completePercentage) { if (!completePercentage) {
return null; return null;
} }
const [showCompletePopover, setShowCompletePopover] = useState(false);
const completeSegmentOffset = (3.6 * completePercentage) / 8; const completeSegmentOffset = (3.6 * completePercentage) / 8;
let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0; let completeTooltipDegree = completePercentage < 100 ? -completeSegmentOffset : 0;
@@ -78,7 +78,7 @@ const CompleteDonutSegment = ({ completePercentage, intl, lockedPercentage }) =>
)} )}
</g> </g>
); );
}; }
CompleteDonutSegment.propTypes = { CompleteDonutSegment.propTypes = {
completePercentage: PropTypes.number.isRequired, completePercentage: PropTypes.number.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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';
const CourseGradeFooter = ({ intl, passingGrade }) => { function CourseGradeFooter({ intl, passingGrade }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -83,7 +83,7 @@ const CourseGradeFooter = ({ intl, passingGrade }) => {
</div> </div>
</div> </div>
); );
}; }
CourseGradeFooter.propTypes = { CourseGradeFooter.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

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';
const CourseGradeHeader = ({ intl }) => { function CourseGradeHeader({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -81,7 +81,7 @@ const CourseGradeHeader = ({ intl }) => {
)} )}
</div> </div>
); );
}; }
CourseGradeHeader.propTypes = { CourseGradeHeader.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

@@ -11,7 +11,7 @@ import PassingGradeTooltip from './PassingGradeTooltip';
import messages from '../messages'; import messages from '../messages';
const GradeBar = ({ intl, passingGrade }) => { function GradeBar({ intl, passingGrade }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -49,7 +49,7 @@ const 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';
const GradeRangeTooltip = ({ intl, iconButtonClassName, passingGrade }) => { function GradeRangeTooltip({ intl, iconButtonClassName, passingGrade }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -72,7 +72,7 @@ const GradeRangeTooltip = ({ intl, iconButtonClassName, passingGrade }) => {
/> />
</OverlayTrigger> </OverlayTrigger>
); );
}; }
GradeRangeTooltip.defaultProps = { GradeRangeTooltip.defaultProps = {
iconButtonClassName: '', iconButtonClassName: '',

View File

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

View File

@@ -12,7 +12,7 @@ import DetailedGradesTable from './DetailedGradesTable';
import messages from '../messages'; import messages from '../messages';
const DetailedGrades = ({ intl }) => { function DetailedGrades({ intl }) {
const { administrator } = getAuthenticatedUser(); const { administrator } = getAuthenticatedUser();
const { const {
courseId, courseId,
@@ -79,7 +79,7 @@ const DetailedGrades = ({ intl }) => {
)} )}
</section> </section>
); );
}; }
DetailedGrades.propTypes = { DetailedGrades.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -10,7 +10,7 @@ import { useModel } from '../../../../generic/model-store';
import messages from '../messages'; import messages from '../messages';
import SubsectionTitleCell from './SubsectionTitleCell'; import SubsectionTitleCell from './SubsectionTitleCell';
const DetailedGradesTable = ({ intl }) => { function DetailedGradesTable({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -64,7 +64,7 @@ const DetailedGradesTable = ({ intl }) => {
); );
}) })
); );
}; }
DetailedGradesTable.propTypes = { DetailedGradesTable.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -8,7 +8,7 @@ import {
import messages from '../messages'; import messages from '../messages';
const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => { function ProblemScoreDrawer({ intl, problemScores, subsection }) {
const isLocaleRtl = isRtl(getLocale()); 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">
@@ -22,7 +22,7 @@ const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => {
</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';
const SubsectionTitleCell = ({ intl, subsection }) => { function SubsectionTitleCell({ intl, subsection }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -99,7 +99,7 @@ const SubsectionTitleCell = ({ intl, subsection }) => {
</Collapsible.Body> </Collapsible.Body>
</Collapsible.Advanced> </Collapsible.Advanced>
); );
}; }
SubsectionTitleCell.propTypes = { SubsectionTitleCell.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

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';
const AssignmentTypeCell = ({ function AssignmentTypeCell({
intl, assignmentType, footnoteMarker, footnoteId, locked, intl, assignmentType, footnoteMarker, footnoteId, locked,
}) => { }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -42,7 +42,7 @@ const AssignmentTypeCell = ({
</div> </div>
</div> </div>
); );
}; }
AssignmentTypeCell.propTypes = { AssignmentTypeCell.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

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';
const DroppableAssignmentFootnote = ({ footnotes, intl }) => { function DroppableAssignmentFootnote({ footnotes, intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -37,7 +37,7 @@ const DroppableAssignmentFootnote = ({ footnotes, intl }) => {
</ul> </ul>
</> </>
); );
}; }
DroppableAssignmentFootnote.propTypes = { DroppableAssignmentFootnote.propTypes = {
footnotes: PropTypes.arrayOf(PropTypes.shape({ footnotes: PropTypes.arrayOf(PropTypes.shape({

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';
const GradeSummary = () => { function GradeSummary() {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -28,6 +28,6 @@ const GradeSummary = () => {
<GradeSummaryTable setAllOfSomeAssignmentTypeIsLocked={setAllOfSomeAssignmentTypeIsLocked} /> <GradeSummaryTable setAllOfSomeAssignmentTypeIsLocked={setAllOfSomeAssignmentTypeIsLocked} />
</section> </section>
); );
}; }
export default GradeSummary; export default GradeSummary;

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';
const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => { function GradeSummaryHeader({ intl, allOfSomeAssignmentTypeIsLocked }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -54,7 +54,7 @@ const GradeSummaryHeader = ({ intl, allOfSomeAssignmentTypeIsLocked }) => {
)} )}
</div> </div>
); );
}; }
GradeSummaryHeader.propTypes = { GradeSummaryHeader.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -14,7 +14,7 @@ import GradeSummaryTableFooter from './GradeSummaryTableFooter';
import messages from '../messages'; import messages from '../messages';
const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => { function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -79,16 +79,6 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, weightedGrade: { weightedGrade: `${(assignment.weightedGrade * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
}; };
}); });
const getAssignmentTypeCell = (value) => (
<AssignmentTypeCell
assignmentType={value.type} // eslint-disable-line react/prop-types
footnoteId={value.footnoteId} // eslint-disable-line react/prop-types
footnoteMarker={value.footnoteMarker} // eslint-disable-line react/prop-types
locked={value.locked} // eslint-disable-line react/prop-types
/>
);
const getCell = (locked, value) => <span className={locked ? 'greyed-out' : ''}>{value}</span>;
return ( return (
<> <>
@@ -99,28 +89,45 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
{ {
Header: `${intl.formatMessage(messages.assignmentType)}`, Header: `${intl.formatMessage(messages.assignmentType)}`,
accessor: 'type', accessor: 'type',
Cell: ({ value }) => getAssignmentTypeCell(value), // eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<AssignmentTypeCell
assignmentType={value.type} // eslint-disable-line react/prop-types
footnoteId={value.footnoteId} // eslint-disable-line react/prop-types
footnoteMarker={value.footnoteMarker} // eslint-disable-line react/prop-types
locked={value.locked} // eslint-disable-line react/prop-types
/>
),
headerClassName: 'h5 mb-0', headerClassName: 'h5 mb-0',
}, },
{ {
Header: `${intl.formatMessage(messages.weight)}`, Header: `${intl.formatMessage(messages.weight)}`,
accessor: 'weight', accessor: 'weight',
headerClassName: 'justify-content-end h5 mb-0', headerClassName: 'justify-content-end h5 mb-0',
Cell: ({ value }) => getCell(value.locked, value.weight), // eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.weight}</span> // eslint-disable-line react/prop-types
),
cellClassName: 'text-right small', cellClassName: 'text-right small',
}, },
{ {
Header: `${intl.formatMessage(messages.grade)}`, Header: `${intl.formatMessage(messages.grade)}`,
accessor: 'grade', accessor: 'grade',
headerClassName: 'justify-content-end h5 mb-0', headerClassName: 'justify-content-end h5 mb-0',
Cell: ({ value }) => getCell(value.locked, value.grade), // eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.grade}</span> // eslint-disable-line react/prop-types
),
cellClassName: 'text-right small', cellClassName: 'text-right small',
}, },
{ {
Header: `${intl.formatMessage(messages.weightedGrade)}`, Header: `${intl.formatMessage(messages.weightedGrade)}`,
accessor: 'weightedGrade', accessor: 'weightedGrade',
headerClassName: 'justify-content-end h5 mb-0 text-right', headerClassName: 'justify-content-end h5 mb-0 text-right',
Cell: ({ value }) => getCell(value.locked, value.weightedGrade), // eslint-disable-next-line react/prop-types
Cell: ({ value }) => (
<span className={value.locked ? 'greyed-out' : ''}>{value.weightedGrade}</span> // eslint-disable-line react/prop-types
),
cellClassName: 'text-right font-weight-bold small', cellClassName: 'text-right font-weight-bold small',
}, },
]} ]}
@@ -134,7 +141,7 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
)} )}
</> </>
); );
}; }
GradeSummaryTable.propTypes = { GradeSummaryTable.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -9,7 +9,7 @@ import { useModel } from '../../../../generic/model-store';
import messages from '../messages'; import messages from '../messages';
const GradeSummaryTableFooter = ({ intl }) => { function GradeSummaryTableFooter({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -34,7 +34,7 @@ const GradeSummaryTableFooter = ({ intl }) => {
</div> </div>
</DataTable.TableFooter> </DataTable.TableFooter>
); );
}; }
GradeSummaryTableFooter.propTypes = { GradeSummaryTableFooter.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -9,7 +9,7 @@ import { Hyperlink } from '@edx/paragon';
import messages from './messages'; import messages from './messages';
import { useModel } from '../../../generic/model-store'; import { useModel } from '../../../generic/model-store';
const RelatedLinks = ({ intl }) => { function RelatedLinks({ intl }) {
const { const {
courseId, courseId,
} = useSelector(state => state.courseHome); } = useSelector(state => state.courseHome);
@@ -56,7 +56,7 @@ const RelatedLinks = ({ intl }) => {
</ul> </ul>
</section> </section>
); );
}; }
RelatedLinks.propTypes = { RelatedLinks.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,16 @@
import React from 'react'; import React from 'react';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { breakpoints } from '@edx/paragon'; import { breakpoints } from '@edx/paragon';
import { import {
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest'; } from '../../setupTest';
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
import { handleNextSectionCelebration } from './celebration'; import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils'; import * as celebrationUtils from './celebration/utils';
import Course from './Course'; import Course from './Course';
import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-platform/analytics');
const recordFirstSectionCelebration = jest.fn(); const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration; celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => { describe('Course', () => {
@@ -49,28 +42,6 @@ describe('Course', () => {
setItemSpy.mockRestore(); setItemSpy.mockRestore();
}); });
const setupDiscussionSidebar = async (storageValue = false) => {
localStorage.clear();
const testStore = await initializeTestStore({ provider: 'openedx' });
const state = testStore.getState();
const { courseware: { courseId } } = state;
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(200, { provider: 'openedx' });
const topicsResponse = buildTopicsFromUnits(state.models.units);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
.reply(200, topicsResponse);
await executeThunk(thunks.getCourseDiscussionTopics(courseId), testStore.dispatch);
const [firstUnitId] = Object.keys(state.models.units);
mockData.unitId = firstUnitId;
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
if (storageValue !== null) {
localStorage.setItem('showDiscussionSidebar', storageValue);
}
await render(<Course {...mockData} />, { store: testStore });
};
it('loads learning sequence', async () => { it('loads learning sequence', async () => {
render(<Course {...mockData} />); render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
@@ -131,7 +102,6 @@ describe('Course', () => {
}); });
it('displays notification trigger and toggles active class on click', async () => { it('displays notification trigger and toggles active class on click', async () => {
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />); render(<Course {...mockData} />);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i }); const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
@@ -143,14 +113,13 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => { it('handles click to open/close notification tray', async () => {
sessionStorage.clear(); sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />); render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"'); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none'); expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
fireEvent.click(notificationShowButton); fireEvent.click(notificationShowButton);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"'); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none'); expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
}); });
it('handles reload persisting notification tray status', async () => { it('handles reload persisting notification tray status', async () => {
@@ -174,7 +143,6 @@ describe('Course', () => {
it('handles sessionStorage from a different course for the notification tray', async () => { it('handles sessionStorage from a different course for the notification tray', async () => {
sessionStorage.clear(); sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' }); const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
// set sessionStorage for a different course before rendering Course // set sessionStorage for a different course before rendering Course
@@ -217,34 +185,6 @@ describe('Course', () => {
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument(); expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
}); });
[
{ value: true, visible: true },
{ value: false, visible: false },
{ value: null, visible: true },
].forEach(async ({ value, visible }) => (
it(`discussion sidebar is ${visible ? 'shown' : 'hidden'} when localstorage value is ${value}`, async () => {
await setupDiscussionSidebar(value);
const element = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
if (visible) {
expect(element).not.toHaveClass('d-none');
} else {
expect(element).toHaveClass('d-none');
}
})));
[
{ value: true, result: 'false' },
{ value: false, result: 'true' },
].forEach(async ({ value, result }) => (
it(`Discussion sidebar storage value is ${!value} when sidebar is ${value ? 'closed' : 'open'}`, async () => {
await setupDiscussionSidebar(value);
await act(async () => {
const button = await screen.queryByRole('button', { name: /Show discussions tray/i });
button.click();
});
expect(localStorage.getItem('showDiscussionSidebar')).toBe(result);
})));
it('passes handlers to the sequence', async () => { it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn(); const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn(); const previousSequenceHandler = jest.fn();
@@ -284,10 +224,11 @@ describe('Course', () => {
describe('Sequence alerts display', () => { describe('Sequence alerts display', () => {
it('renders banner text alert', async () => { it('renders banner text alert', async () => {
const courseMetadata = Factory.build('courseMetadata'); const courseMetadata = Factory.build('courseMetadata');
const sequenceBlocks = [Factory.build('block', { type: 'sequential', banner_text: 'Some random banner text to display.' })]; const sequenceBlocks = [Factory.build(
'block', { type: 'sequential', banner_text: 'Some random banner text to display.' },
)];
const sequenceMetadata = [Factory.build( const sequenceMetadata = [Factory.build(
'sequenceMetadata', 'sequenceMetadata', { banner_text: sequenceBlocks[0].banner_text },
{ banner_text: sequenceBlocks[0].banner_text },
{ courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] }, { courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] },
)]; )];

View File

@@ -8,11 +8,13 @@ import { useSelector } from 'react-redux';
import { SelectMenu } from '@edx/paragon'; import { SelectMenu } from '@edx/paragon';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useModel, useModels } from '../../generic/model-store'; import { useModel, useModels } from '../../generic/model-store';
/** [MM-P2P] Experiment */
import { MMP2PFlyoverTrigger } from '../../experiments/mm-p2p';
import JumpNavMenuItem from './JumpNavMenuItem'; import JumpNavMenuItem from './JumpNavMenuItem';
const CourseBreadcrumb = ({ function CourseBreadcrumb({
content, withSeparator, courseId, sequenceId, unitId, isStaff, content, withSeparator, courseId, sequenceId, unitId, isStaff,
}) => { }) {
const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] }; const defaultContent = content.filter(destination => destination.default)[0] || { id: courseId, label: '', sequences: [] };
return ( return (
<> <>
@@ -55,7 +57,7 @@ const CourseBreadcrumb = ({
</li> </li>
</> </>
); );
}; }
CourseBreadcrumb.propTypes = { CourseBreadcrumb.propTypes = {
content: PropTypes.arrayOf( content: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
@@ -79,13 +81,15 @@ CourseBreadcrumb.defaultProps = {
isStaff: null, isStaff: null,
}; };
const CourseBreadcrumbs = ({ export default function CourseBreadcrumbs({
courseId, courseId,
sectionId, sectionId,
sequenceId, sequenceId,
unitId, unitId,
isStaff, isStaff,
}) => { /** [MM-P2P] Experiment */
mmp2p,
}) {
const course = useModel('coursewareMeta', courseId); const course = useModel('coursewareMeta', courseId);
const courseStatus = useSelector(state => state.courseware.courseStatus); const courseStatus = useSelector(state => state.courseware.courseStatus);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
@@ -148,10 +152,14 @@ const CourseBreadcrumbs = ({
isStaff={isStaff} isStaff={isStaff}
/> />
))} ))}
{/** [MM-P2P] Experiment */}
{mmp2p.state && mmp2p.state.isEnabled && (
<MMP2PFlyoverTrigger options={mmp2p} />
)}
</ol> </ol>
</nav> </nav>
); );
}; }
CourseBreadcrumbs.propTypes = { CourseBreadcrumbs.propTypes = {
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
@@ -159,6 +167,12 @@ CourseBreadcrumbs.propTypes = {
sequenceId: PropTypes.string, sequenceId: PropTypes.string,
unitId: PropTypes.string, unitId: PropTypes.string,
isStaff: PropTypes.bool, isStaff: PropTypes.bool,
/** [MM-P2P] Experiment */
mmp2p: PropTypes.shape({
state: PropTypes.shape({
isEnabled: PropTypes.bool.isRequired,
}),
}),
}; };
CourseBreadcrumbs.defaultProps = { CourseBreadcrumbs.defaultProps = {
@@ -166,6 +180,6 @@ CourseBreadcrumbs.defaultProps = {
sequenceId: null, sequenceId: null,
unitId: null, unitId: null,
isStaff: null, isStaff: null,
/** [MM-P2P] Experiment */
mmp2p: {},
}; };
export default CourseBreadcrumbs;

View File

@@ -8,14 +8,14 @@ import {
sendTrackEvent, sendTrackEvent,
} from '@edx/frontend-platform/analytics'; } from '@edx/frontend-platform/analytics';
const JumpNavMenuItem = ({ export default function JumpNavMenuItem({
title, title,
courseId, courseId,
currentSequence, currentSequence,
currentUnit, currentUnit,
sequences, sequences,
isDefault, isDefault,
}) => { }) {
function logEvent(targetUrl) { function logEvent(targetUrl) {
const eventName = 'edx.ui.lms.jump_nav.selected'; const eventName = 'edx.ui.lms.jump_nav.selected';
const payload = { const payload = {
@@ -48,7 +48,7 @@ const JumpNavMenuItem = ({
{title} {title}
</MenuItem> </MenuItem>
); );
}; }
const sequenceShape = PropTypes.shape({ const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
@@ -62,5 +62,3 @@ JumpNavMenuItem.propTypes = {
currentSequence: PropTypes.string.isRequired, currentSequence: PropTypes.string.isRequired,
currentUnit: PropTypes.string.isRequired, currentUnit: PropTypes.string.isRequired,
}; };
export default JumpNavMenuItem;

View File

@@ -23,9 +23,9 @@ const hasBookmarkLabel = (
/> />
); );
const BookmarkButton = ({ export default function BookmarkButton({
isBookmarked, isProcessing, unitId, isBookmarked, isProcessing, unitId,
}) => { }) {
const bookmarkState = isBookmarked ? 'bookmarked' : 'default'; const bookmarkState = isBookmarked ? 'bookmarked' : 'default';
const state = isProcessing ? `${bookmarkState}Processing` : bookmarkState; const state = isProcessing ? `${bookmarkState}Processing` : bookmarkState;
@@ -36,7 +36,6 @@ const BookmarkButton = ({
} else { } else {
dispatch(addBookmark(unitId)); dispatch(addBookmark(unitId));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isBookmarked, unitId]); }, [isBookmarked, unitId]);
return ( return (
@@ -60,7 +59,7 @@ const BookmarkButton = ({
}} }}
/> />
); );
}; }
BookmarkButton.propTypes = { BookmarkButton.propTypes = {
unitId: PropTypes.string.isRequired, unitId: PropTypes.string.isRequired,
@@ -71,5 +70,3 @@ BookmarkButton.propTypes = {
BookmarkButton.defaultProps = { BookmarkButton.defaultProps = {
isBookmarked: false, isBookmarked: false,
}; };
export default BookmarkButton;

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