Compare commits

..

19 Commits

Author SHA1 Message Date
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
300 changed files with 42188 additions and 23317 deletions

4
.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=''
@@ -46,4 +43,3 @@ TERMS_OF_SERVICE_URL=''
TWITTER_HASHTAG='' TWITTER_HASHTAG=''
TWITTER_URL='' TWITTER_URL=''
USER_INFO_COOKIE_NAME='' USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_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=''
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=''
@@ -46,6 +43,3 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
SESSION_COOKIE_DOMAIN='localhost' SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_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=''
@@ -45,4 +42,3 @@ TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
TWITTER_HASHTAG='myedxjourney' TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline' TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info' USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'

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', {
overrides: [{
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"],
rules: { rules: {
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release. 'import/named': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'import/no-extraneous-dependencies': 'off', 'import/no-extraneous-dependencies': 'off',
'no-restricted-exports': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/no-unknown-property': 'off',
'func-names': 'off',
}, },
}],
}); });
module.exports = config;

View File

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

@@ -10,4 +10,4 @@ on:
jobs: jobs:
version-check: version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master uses: openedx/.github/.github/workflows/lockfileversion-check.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

@@ -9,13 +9,14 @@ on:
jobs: jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: ${{ env.NODE_VER }} node-version: ${{ matrix.node }}
- run: make validate.ci - run: make validate.ci
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3

2
.gitignore vendored
View File

@@ -1,8 +1,6 @@
.DS_Store .DS_Store
.eslintcache .eslintcache
.idea .idea
*.swp
*.swo
node_modules node_modules
npm-debug.log npm-debug.log
coverage coverage

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

@@ -1,7 +1,6 @@
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,fa_IR,fr_CA,it_IT,pt_PT,de_DE" transifex_langs = "ar,fr,es_419,zh_CN"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json transifex_input = $(i18n)/transifex_input.json
@@ -43,24 +42,9 @@ push_translations:
# Pushing comments to Transifex... # Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh ./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex. # Pulls translations from Transifex.
pull_translations: pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs) tx pull -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-learning
endif
# This target is used by Travis. # This target is used by Travis.
validate-no-uncommitted-package-lock-changes: validate-no-uncommitted-package-lock-changes:

View File

@@ -9,12 +9,4 @@ module.exports = createConfig('jest', {
'src/i18n', 'src/i18n',
'src/.*\\.exp\\..*', 'src/.*\\.exp\\..*',
], ],
// see https://github.com/axios/axios/issues/5026
moduleNameMapper: {
"^axios$": "axios/dist/axios.js",
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
},
testTimeout: 30000,
testEnvironment: 'jsdom'
}); });

50314
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,54 +29,51 @@
"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": "12.2.1", "@edx/frontend-component-footer": "^12.0.0",
"@edx/frontend-component-header": "4.6.0", "@edx/frontend-component-header": "^4.0.0",
"@edx/frontend-lib-learning-assistant": "^1.14.0", "@edx/frontend-lib-special-exams": "^2.16.1",
"@edx/frontend-lib-special-exams": "2.23.2", "@edx/frontend-platform": "^4.2.0",
"@edx/frontend-platform": "5.5.2", "@edx/paragon": "^20.28.4",
"@edx/paragon": "20.46.0",
"@edx/react-unit-test-utils": "npm:@edx/react-unit-test-utils@1.7.0",
"@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.4", "@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.8", "@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",
"js-cookie": "3.0.5", "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": "17.0.2", "react": "16.14.0",
"react-dom": "17.0.2", "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": "6.15.0", "react-router": "5.2.1",
"react-router-dom": "6.15.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.8", "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.2.0", "@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "^12.9.10", "@edx/frontend-build": "9.1.4",
"@edx/reactifex": "2.2.0", "@edx/reactifex": "2.0.1",
"@pact-foundation/pact": "^11.0.2", "@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": "29.5.0", "jest": "27.5.1",
"rosie": "2.1.0" "rosie": "2.1.0"
} }
} }

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}>
@@ -68,7 +69,7 @@ const CourseStartAlert = ({ payload }) => {
<Alert variant="info" icon={Info}> <Alert variant="info" icon={Info}>
<strong> <strong>
<FormattedMessage <FormattedMessage
id="learning.outline.alert.start.long" id="learning.outline.alert.end.long"
defaultMessage="Course starts {timeRemaining} on {courseStartDate}." defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
description="Used when the time remaining is more than a day away." description="Used when the time remaining is more than a day away."
values={{ values={{
@@ -88,13 +89,13 @@ const CourseStartAlert = ({ payload }) => {
</strong> </strong>
<br /> <br />
<FormattedMessage <FormattedMessage
id="learning.outline.alert.start.calendar" id="learning.outline.alert.end.calendar"
defaultMessage="Dont forget to add a calendar reminder!" defaultMessage="Dont forget to add a calendar reminder!"
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. " description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
/> />
</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

@@ -1,33 +0,0 @@
export const DECODE_ROUTES = {
ACCESS_DENIED: '/course/:courseId/access-denied',
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
],
COURSE_END: '/course/:courseId/course-end',
COURSEWARE: [
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
'/course/:courseId',
],
REDIRECT_HOME: 'home/:courseId',
REDIRECT_SURVEY: 'survey/:courseId',
};
export const ROUTES = {
UNSUBSCRIBE: '/goal-unsubscribe/:token',
REDIRECT: '/redirect/*',
DASHBOARD: 'dashboard',
CONSENT: 'consent',
};
export const REDIRECT_MODES = {
DASHBOARD_REDIRECT: 'dashboard-redirect',
CONSENT_REDIRECT: 'consent-redirect',
HOME_REDIRECT: 'home-redirect',
SURVEY_REDIRECT: 'survey-redirect',
};

View File

@@ -34,7 +34,8 @@ Factory.define('courseHomeMetadata')
currency_symbol: '$', currency_symbol: '$',
}, },
}) })
.attr('tabs', ['id', 'host'], (id, host) => [ .attr(
'tabs', ['id', 'host'], (id, host) => [
Factory.build( Factory.build(
'tab', 'tab',
{ {
@@ -119,4 +120,5 @@ Factory.define('courseHomeMetadata')
path: 'dates', path: 'dates',
}, },
), ),
]); ],
);

View File

@@ -28,7 +28,6 @@ Factory.define('outlineTabData')
upgrade_url: `${host}/dashboard`, upgrade_url: `${host}/dashboard`,
})) }))
.attrs({ .attrs({
course_access_redirect: false,
has_scheduled_content: null, has_scheduled_content: null,
access_expiration: null, access_expiration: null,
can_show_upgrade_sock: false, can_show_upgrade_sock: false,

View File

@@ -18,9 +18,6 @@ Object {
"sequenceMightBeUnit": false, "sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
@@ -339,9 +336,6 @@ Object {
"sequenceMightBeUnit": false, "sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {
@@ -538,9 +532,6 @@ Object {
"sequenceMightBeUnit": false, "sequenceMightBeUnit": false,
"sequenceStatus": "loading", "sequenceStatus": "loading",
}, },
"learningAssistant": ObjectContaining {
"conversationId": Any<String>,
},
"models": Object { "models": Object {
"courseHomeMeta": Object { "courseHomeMeta": Object {
"course-v1:edX+DemoX+Demo_Course": Object { "course-v1:edX+DemoX+Demo_Course": Object {

View File

@@ -204,18 +204,12 @@ export async function getDatesTabData(courseId) {
const { data } = await getAuthenticatedHttpClient().get(url); const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data); return camelCaseObject(data);
} catch (error) { } catch (error) {
const httpErrorStatus = error?.response?.status; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 401) { if (httpErrorStatus === 401) {
// The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining // The backend sends this for unenrolled and unauthenticated learners, but we handle those cases by examining
// courseAccess in the metadata call, so just ignore this status for now. // courseAccess in the metadata call, so just ignore this status for now.
return {}; return {};
} }
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error; throw error;
} }
} }
@@ -265,7 +259,7 @@ export async function getProgressTabData(courseId, targetUserId) {
return camelCasedData; return camelCasedData;
} catch (error) { } catch (error) {
const httpErrorStatus = error?.response?.status; const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) { if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`); global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/progress`);
return {}; return {};
@@ -275,12 +269,6 @@ export async function getProgressTabData(courseId, targetUserId) {
// courseAccess in the metadata call, so just ignore this status for now. // courseAccess in the metadata call, so just ignore this status for now.
return {}; return {};
} }
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error; throw error;
} }
} }
@@ -334,20 +322,7 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
export async function getOutlineTabData(courseId) { export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`; const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now(); const requestTime = Date.now();
let tabData; const tabData = await getAuthenticatedHttpClient().get(url);
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const httpErrorStatus = error?.response?.status;
if (httpErrorStatus === 403) {
// The backend sends this if there is a course access error and the user should be redirected. The redirect
// info is included in the course metadata request and will be handled there as long as this call returns
// without an error
return {};
}
throw error;
}
const responseTime = Date.now(); const responseTime = Date.now();
const { const {
@@ -374,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

@@ -1,6 +1,6 @@
import { Pact, Matchers } from '@pact-foundation/pact';
import path from 'path'; import path from 'path';
import { mergeConfig, getConfig } from '@edx/frontend-platform'; import { mergeConfig, getConfig } from '@edx/frontend-platform';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { import {
getCourseHomeCourseMetadata, getCourseHomeCourseMetadata,
@@ -14,8 +14,8 @@ import {
const { const {
somethingLike: like, term, boolean, string, eachLike, somethingLike: like, term, boolean, string, eachLike,
} = MatchersV3; } = Matchers;
const provider = new PactV3({ const provider = new Pact({
consumer: 'frontend-app-learning', consumer: 'frontend-app-learning',
provider: 'lms', provider: 'lms',
log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'), log: path.resolve(process.cwd(), 'src/course-home/data/pact-tests/logs', 'pact.log'),
@@ -28,15 +28,18 @@ const provider = new PactV3({
describe('Course Home Service', () => { describe('Course Home Service', () => {
beforeAll(async () => { beforeAll(async () => {
initializeMockApp(); initializeMockApp();
mergeConfig({ await provider
LMS_BASE_URL: 'http://localhost:8081', .setup()
}, 'Custom app config for pact tests'); .then((options) => mergeConfig({
LMS_BASE_URL: `http://localhost:${options.port}`,
}, 'Custom app config for pact tests'));
}); });
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
describe('When a request to fetch tab is made', () => { describe('When a request to fetch tab is made', () => {
it('returns tab data for a course_id', async () => { it('returns tab data for a course_id', async () => {
setTimeout(() => { await provider.addInteraction({
provider.addInteraction({
state: `Tab data exists for course_id ${courseId}`, state: `Tab data exists for course_id ${courseId}`,
uponReceiving: 'a request to fetch tab', uponReceiving: 'a request to fetch tab',
withRequest: { withRequest: {
@@ -134,17 +137,15 @@ describe('Course Home Service', () => {
title: 'Demonstration Course', title: 'Demonstration Course',
username: 'edx', username: 'edx',
}; };
const response = getCourseHomeCourseMetadata(courseId, 'outline'); const response = await getCourseHomeCourseMetadata(courseId, 'outline');
expect(response).toBeTruthy(); expect(response).toBeTruthy();
expect(response).toEqual(normalizedTabData); expect(response).toEqual(normalizedTabData);
}, 100);
}); });
}); });
describe('When a request to fetch dates tab is made', () => { describe('When a request to fetch dates tab is made', () => {
it('returns course date blocks for a course_id', async () => { it('returns course date blocks for a course_id', async () => {
setTimeout(() => { await provider.addInteraction({
provider.addInteraction({
state: `course date blocks exist for course_id ${courseId}`, state: `course date blocks exist for course_id ${courseId}`,
uponReceiving: 'a request to fetch dates tab', uponReceiving: 'a request to fetch dates tab',
withRequest: { withRequest: {
@@ -211,10 +212,10 @@ describe('Course Home Service', () => {
learnerIsFullAccess: true, learnerIsFullAccess: true,
userTimezone: null, userTimezone: null,
}; };
const response = getDatesTabData(courseId);
const response = await getDatesTabData(courseId);
expect(response).toBeTruthy(); expect(response).toBeTruthy();
expect(response).toEqual(camelCaseResponse); expect(response).toEqual(camelCaseResponse);
}, 100);
}); });
}); });
}); });

View File

@@ -21,18 +21,6 @@ describe('Data layer integration tests', () => {
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeAccessDeniedMetadata = Factory.build(
'courseHomeMetadata',
{
id: courseId,
course_access: {
has_access: false,
error_code: 'bad codes',
additional_context_user_message: 'your Codes Are BAD',
},
},
);
let store; let store;
beforeEach(() => { beforeEach(() => {
@@ -67,40 +55,16 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toMatchSnapshot();
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
}); });
}); });
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).reply(errorStatus, {});
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 401 || errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchOutlineTab', () => { describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`; const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/outline`;
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
it('Should result in fetch failure if error occurs', async () => { it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseMetadataUrl).networkError(); axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(outlineUrl).networkError(); axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
@@ -111,6 +75,8 @@ describe('Data layer integration tests', () => {
it('Should fetch, normalize, and save metadata', async () => { it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId }); const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData); axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
@@ -118,33 +84,10 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toMatchSnapshot();
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
}); });
}); });
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(outlineUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
let expectedState = 'failed';
if (errorStatus === 403) {
expectedState = 'denied';
}
expect(store.getState().courseHome.courseStatus).toEqual(expectedState);
},
);
});
describe('Test fetchProgressTab', () => { describe('Test fetchProgressTab', () => {
const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`; const progressBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/progress`;
@@ -170,14 +113,7 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded'); expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot({ expect(state).toMatchSnapshot();
// The Xpert chatbot (frontend-lib-learning-assistant) generates a unique UUID
// to keep track of conversations. This causes snapshots to fail, because this UUID
// is generated on each run of the snapshot. Instead, we use an asymmetric matcher here.
learningAssistant: expect.objectContaining({
conversationId: expect.any(String),
}),
});
}); });
it('Should handle the url including a targetUserId', async () => { it('Should handle the url including a targetUserId', async () => {
@@ -193,19 +129,6 @@ describe('Data layer integration tests', () => {
const state = store.getState(); const state = store.getState();
expect(state.courseHome.targetUserId).toEqual(2); expect(state.courseHome.targetUserId).toEqual(2);
}); });
it.each([401, 403, 404])(
'should result in fetch denied for expected errors and failed for all others',
async (errorStatus) => {
const progressUrl = `${progressBaseUrl}/${courseId}`;
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeAccessDeniedMetadata);
axiosMock.onGet(progressUrl).reply(errorStatus, {});
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
expect(store.getState().courseHome.courseStatus).toEqual('denied');
},
);
}); });
describe('Test saveCourseGoal', () => { describe('Test saveCourseGoal', () => {

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

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Routes, Route } from 'react-router-dom'; import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
@@ -32,16 +32,11 @@ describe('DatesTab', () => {
component = ( component = (
<AppProvider store={store}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<Routes> <Route path="/course/:courseId/dates">
<Route
path="/course/:courseId/dates"
element={(
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome"> <TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab /> <DatesTab />
</TabContainer> </TabContainer>
)} </Route>
/>
</Routes>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );

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

@@ -2,20 +2,21 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl } from '@edx/frontend-platform/i18n'; import { injectIntl } from '@edx/frontend-platform/i18n';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useParams, generatePath, useNavigate } from 'react-router-dom'; import { generatePath, useHistory } from 'react-router';
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);
const navigate = useNavigate(); const history = useHistory();
const [, iFrameHeight] = useIFrameHeight(); const [, iFrameHeight] = useIFrameHeight();
useIFramePluginEvents({ useIFramePluginEvents({
'discussions.navigate': (payload) => { 'discussions.navigate': (payload) => {
const basePath = generatePath('/course/:courseId/discussion', { courseId }); const basePath = generatePath('/course/:courseId/discussion', { courseId });
navigate(`${basePath}/${payload.path}`); history.push(`${basePath}/${payload.path}`);
}, },
}); });
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`; const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/${originalPath}`;
@@ -28,7 +29,7 @@ const DiscussionTab = () => {
title="discussion" title="discussion"
/> />
); );
}; }
DiscussionTab.propTypes = {}; DiscussionTab.propTypes = {};

View File

@@ -4,7 +4,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import React from 'react'; import React from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route } from 'react-router';
import { Factory } from 'rosie'; import { Factory } from 'rosie';
import { UserMessagesProvider } from '../../generic/user-messages'; import { UserMessagesProvider } from '../../generic/user-messages';
import { import {
@@ -30,16 +30,11 @@ describe('DiscussionTab', () => {
component = ( component = (
<AppProvider store={store}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<Routes> <Route path="/course/:courseId/discussion">
<Route
path="/course/:courseId/discussion"
element={(
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome"> <TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab /> <DiscussionTab />
</TabContainer> </TabContainer>
)} </Route>
/>
</Routes>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );

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

@@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
import { import { Route } from 'react-router';
MemoryRouter, Route, Routes,
} from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform'; import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
@@ -26,16 +24,13 @@ describe('GoalUnsubscribe', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore(); store = initializeStore();
component = ( component = (
<AppProvider store={store} wrapWithRouter={false}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<MemoryRouter initialEntries={['/goal-unsubscribe/TOKEN']}> <Route path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<Routes>
<Route path="/goal-unsubscribe/:token" element={<GoalUnsubscribe />} />
</Routes>
</MemoryRouter>
</UserMessagesProvider> </UserMessagesProvider>
</AppProvider> </AppProvider>
); );
history.push('/goal-unsubscribe/TOKEN'); // so we can pull token from url
}); });
it('starts with a spinner', () => { it('starts with a spinner', () => {

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,6 +55,20 @@ const DateSummary = ({
/> />
</div> </div>
</div> </div>
{/** [MM-P2P] Experiment (conditional) */}
{ showMMP2P ? (
<div className="row ml-4 pr-2">
<div className="date-summary-text">
<div className="font-weight-bold mt-2">
Last chance to upgrade
</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="row ml-4 pr-2">
<div className="date-summary-text"> <div className="date-summary-text">
{linkedTitle && ( {linkedTitle && (
@@ -73,9 +93,10 @@ const DateSummary = ({
</a> </a>
)} )}
</div> </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

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages'; import { AlertList } from '../../generic/user-messages';
@@ -28,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,
@@ -66,7 +70,6 @@ const OutlineTab = ({ intl }) => {
} = useModel('coursewareMeta', courseId); } = useModel('coursewareMeta', courseId);
const [expandAll, setExpandAll] = useState(false); const [expandAll, setExpandAll] = useState(false);
const navigate = useNavigate();
const eventProperties = { const eventProperties = {
org_key: org, org_key: org,
@@ -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,15 +116,13 @@ 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.
currentParams.delete('start_course'); currentParams.delete('start_course');
navigate({ history.replace({
pathname: location.pathname, search: currentParams.toString(),
search: `?${currentParams.toString()}`,
replace: true,
}); });
} }
}, [location.search]); }, [location.search]);
@@ -130,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">
@@ -141,6 +146,9 @@ const OutlineTab = ({ intl }) => {
/> />
</div> </div>
<div className="col col-12 col-md-8"> <div className="col col-12 col-md-8">
{ /** [MM-P2P] Experiment (the conditional) */ }
{ !MMP2P.state.isEnabled
&& (
<AlertList <AlertList
topic="outline-course-alerts" topic="outline-course-alerts"
className="mb-3" className="mb-3"
@@ -151,7 +159,8 @@ const OutlineTab = ({ intl }) => {
...scheduledContentAlert, ...scheduledContentAlert,
}} }}
/> />
{isSelfPaced && hasDeadlines && ( )}
{isSelfPaced && hasDeadlines && !MMP2P.state.isEnabled && (
<> <>
<ShiftDatesAlert model="outline" fetch={fetchOutlineTab} /> <ShiftDatesAlert model="outline" fetch={fetchOutlineTab} />
<UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} /> <UpgradeToShiftDatesAlert model="outline" logUpgradeLinkClick={logUpgradeToShiftDatesLinkClick} />
@@ -194,6 +203,10 @@ const OutlineTab = ({ intl }) => {
/> />
)} )}
<CourseTools /> <CourseTools />
{ /** [MM-P2P] Experiment (conditional) */ }
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeNotification <UpgradeNotification
offer={offer} offer={offer}
verifiedMode={verifiedMode} verifiedMode={verifiedMode}
@@ -207,14 +220,18 @@ const OutlineTab = ({ intl }) => {
courseId={courseId} courseId={courseId}
org={org} org={org}
/> />
<CourseDates /> )}
<CourseDates
/** [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CourseHandouts /> <CourseHandouts />
</div> </div>
)} )}
</div> </div>
</> </>
); );
}; }
OutlineTab.propTypes = { OutlineTab.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -119,11 +119,11 @@ describe('Outline Tab', () => {
// Click to expand section // Click to expand section
userEvent.click(expandButton); userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true')); expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section // Click to collapse section
userEvent.click(expandButton); userEvent.click(expandButton);
await waitFor(() => expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false')); expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
}); });
it('displays correct icon for complete assignment', async () => { it('displays correct icon for complete assignment', async () => {
@@ -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,11 +383,12 @@ 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}
`('calls the API with a goal of $days when $level goal is clicked', async ({ level, days }) => {
// click on Casual goal // click on Casual goal
const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`); const button = await screen.queryByTestId(`weekly-learning-goal-input-${level}`);
fireEvent.click(button); fireEvent.click(button);
@@ -401,7 +402,6 @@ describe('Outline Tab', () => {
}); });
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>
{due && (
<div className="row w-100 m-0 ml-3 pl-3"> <div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2"> <small className="text-body pl-2">
{due ? dueDateMessage : noDueDateMessage} <FormattedMessage
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> </small>
</div> </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,19 +2,18 @@ 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,
}) => ( }) {
return (
<button <button
type="button" type="button"
className={classnames( className={classnames('flag-button row w-100 align-content-between m-1.5 py-3.5',
'flag-button row w-100 align-content-between m-1.5 py-3.5', isSelected ? 'flag-button-selected' : '')}
isSelected ? 'flag-button-selected' : '',
)}
aria-checked={isSelected} aria-checked={isSelected}
role="radio" role="radio"
onClick={() => handleSelect()} onClick={() => handleSelect()}
@@ -31,6 +30,7 @@ const FlagButton = ({
</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,6 +26,7 @@ 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"> <div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
<h1>{pageTitle}</h1> <h1>{pageTitle}</h1>
{administrator && studioUrl && ( {administrator && studioUrl && (
@@ -34,8 +35,9 @@ const ProgressHeader = ({ intl }) => {
</Button> </Button>
)} )}
</div> </div>
</>
); );
}; }
ProgressHeader.propTypes = { ProgressHeader.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -12,7 +12,7 @@ import RelatedLinks from './related-links/RelatedLinks';
import { useModel } from '../../generic/model-store'; import { useModel } from '../../generic/model-store';
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,7 +4,8 @@ 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 }) {
return (
<section className="text-dark-700 mb-4 rounded raised-card p-4"> <section className="text-dark-700 mb-4 rounded raised-card p-4">
<div className="row w-100 m-0"> <div className="row w-100 m-0">
<div className="col-12 col-sm-6 col-md-7 p-0"> <div className="col-12 col-sm-6 col-md-7 p-0">
@@ -19,6 +20,7 @@ const CourseCompletion = ({ intl }) => (
</div> </div>
</section> </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,

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