Compare commits

..

3 Commits

Author SHA1 Message Date
mashal-m
891d123f84 fix: update lock file 2022-12-14 14:49:22 +05:00
mashal-m
d9831b4dc7 fix: update lock file 2022-12-13 16:27:33 +05:00
mashal-m
f8215e3e46 build: major paragon version upgrade 2022-12-13 16:19:19 +05:00
235 changed files with 35073 additions and 4735 deletions

3
.env
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

33965
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,50 +29,48 @@
"url": "https://github.com/openedx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "2.10.0",
"@edx/frontend-platform": "4.1.0",
"@edx/paragon": "20.28.4",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.1.0",
"@edx/frontend-component-header": "3.1.0",
"@edx/frontend-lib-special-exams": "2.1.0",
"@edx/frontend-platform": "2.5.1",
"@edx/paragon": "^20.20.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.18",
"@popperjs/core": "2.11.6",
"@popperjs/core": "2.11.5",
"@reduxjs/toolkit": "1.8.1",
"classnames": "2.3.2",
"classnames": "2.3.1",
"core-js": "3.22.2",
"history": "5.3.0",
"html-react-parser": "^3.0.15",
"history": "^5.3.0",
"js-cookie": "3.0.1",
"lodash.camelcase": "4.3.0",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"query-string": "^7.1.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-redux": "7.2.8",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-share": "4.4.1",
"react-share": "4.4.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"regenerator-runtime": "0.13.9",
"reselect": "4.1.5",
"truncate-html": "1.0.4",
"util": "0.12.5"
"util": "0.12.4"
},
"devDependencies": {
"@edx/browserslist-config": "1.1.1",
"@edx/frontend-build": "^12.4.15",
"@edx/reactifex": "2.1.1",
"@edx/browserslist-config": "1.0.2",
"@edx/frontend-build": "9.1.4",
"@edx/reactifex": "2.0.1",
"@pact-foundation/pact": "9.17.3",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"copy-webpack-plugin": "^11.0.0",
"es-check": "6.2.1",
"husky": "7.0.4",
"jest": "27.5.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { unsubscribeFromCourseGoal } from '../data/api';
import messages from './messages';
import ResultPage from './ResultPage';
const GoalUnsubscribe = ({ intl }) => {
function GoalUnsubscribe({ intl }) {
const { token } = useParams();
const [error, setError] = useState(false);
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.
// The token can be used to connect a user and course, it will just require some post-processing
sendTrackEvent('edx.ui.lms.goal.unsubscribe', { token });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // deps=[] to only run once
return (
@@ -49,7 +48,7 @@ const GoalUnsubscribe = ({ intl }) => {
</main>
</>
);
};
}
GoalUnsubscribe.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
const StartOrResumeCourseCard = ({ intl }) => {
function StartOrResumeCourseCard({ intl }) {
const {
courseId,
} = 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 */}
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
<Card.Footer><></></Card.Footer>
</Card>
);
};
}
StartOrResumeCourseCard.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import PassingGradeTooltip from './PassingGradeTooltip';
import messages from '../messages';
const GradeBar = ({ intl, passingGrade }) => {
function GradeBar({ intl, passingGrade }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -49,7 +49,7 @@ const GradeBar = ({ intl, passingGrade }) => {
</svg>
</div>
);
};
}
GradeBar.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import {
import messages from '../messages';
const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => {
function ProblemScoreDrawer({ intl, problemScores, subsection }) {
const isLocaleRtl = isRtl(getLocale());
return (
<span className="row w-100 m-0 x-small ml-4 pt-2 pl-1 text-gray-700 flex-nowrap">
@@ -22,7 +22,7 @@ const ProblemScoreDrawer = ({ intl, problemScores, subsection }) => {
</div>
</span>
);
};
}
ProblemScoreDrawer.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ import GradeSummaryTableFooter from './GradeSummaryTableFooter';
import messages from '../messages';
const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
function GradeSummaryTable({ intl, setAllOfSomeAssignmentTypeIsLocked }) {
const {
courseId,
} = useSelector(state => state.courseHome);
@@ -79,16 +79,6 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
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 (
<>
@@ -99,28 +89,45 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
{
Header: `${intl.formatMessage(messages.assignmentType)}`,
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',
},
{
Header: `${intl.formatMessage(messages.weight)}`,
accessor: 'weight',
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',
},
{
Header: `${intl.formatMessage(messages.grade)}`,
accessor: 'grade',
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',
},
{
Header: `${intl.formatMessage(messages.weightedGrade)}`,
accessor: 'weightedGrade',
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',
},
]}
@@ -134,7 +141,7 @@ const GradeSummaryTable = ({ intl, setAllOfSomeAssignmentTypeIsLocked }) => {
)}
</>
);
};
}
GradeSummaryTable.propTypes = {
intl: intlShape.isRequired,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
@@ -18,7 +18,10 @@ import SidebarTriggers from './sidebar/SidebarTriggers';
import { useModel } from '../../generic/model-store';
import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage';
const Course = ({
/** [MM-P2P] Experiment */
import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
function Course({
courseId,
sequenceId,
unitId,
@@ -26,7 +29,7 @@ const Course = ({
previousSequenceHandler,
unitNavigationHandler,
windowWidth,
}) => {
}) {
const course = useModel('coursewareMeta', courseId);
const {
celebrations,
@@ -43,8 +46,10 @@ const Course = ({
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const dispatch = useDispatch();
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false);
const celebrateFirstSection = celebrations && celebrations.firstSection;
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad(
courseId, sequenceId, celebrateFirstSection, dispatch, celebrations,
));
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
// the weekly goal celebration modal.
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
@@ -66,16 +71,8 @@ const Course = ({
}
}
useEffect(() => {
const celebrateFirstSection = celebrations && celebrations.firstSection;
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
courseId,
sequenceId,
celebrateFirstSection,
dispatch,
celebrations,
));
}, [sequenceId]);
/** [MM-P2P] Experiment */
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);
return (
<SidebarProvider courseId={courseId} unitId={unitId}>
@@ -89,6 +86,8 @@ const Course = ({
sequenceId={sequenceId}
isStaff={isStaff}
unitId={unitId}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
{shouldDisplayTriggers && (
<SidebarTriggers />
@@ -103,6 +102,8 @@ const Course = ({
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<CelebrationModal
courseId={courseId}
@@ -116,9 +117,11 @@ const Course = ({
onClose={() => setWeeklyGoalCelebrationOpen(false)}
/>
<ContentTools course={course} />
{ /** [MM-P2P] Experiment */ }
{ MMP2P.meta.modalLock && <MMP2PBlockModal options={MMP2P} /> }
</SidebarProvider>
);
};
}
Course.propTypes = {
courseId: PropTypes.string,
@@ -136,7 +139,7 @@ Course.defaultProps = {
unitId: null,
};
const CourseWrapper = (props) => {
function CourseWrapper(props) {
// useWindowSize initially returns an undefined width intentionally at first.
// See https://www.joshwcomeau.com/react/the-perils-of-rehydration/ for why.
// But <Course> has some tricky window-size-dependent, session-storage-setting logic and React would yell at us if
@@ -148,6 +151,6 @@ const CourseWrapper = (props) => {
}
return <Course {...props} windowWidth={windowWidth} />;
};
}
export default CourseWrapper;

View File

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

View File

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

View File

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

View File

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

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