Compare commits
1 Commits
djoy/hacka
...
REV1512fly
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de94d00da |
1
.env
1
.env
@@ -17,7 +17,6 @@ FAVICON_URL=null
|
|||||||
MARKETING_SITE_BASE_URL=null
|
MARKETING_SITE_BASE_URL=null
|
||||||
ORDER_HISTORY_URL=null
|
ORDER_HISTORY_URL=null
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||||
SEARCH_CATALOG_URL=null
|
|
||||||
SEGMENT_KEY=null
|
SEGMENT_KEY=null
|
||||||
SITE_NAME=null
|
SITE_NAME=null
|
||||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=null
|
SOCIAL_UTM_MILESTONE_CAMPAIGN=null
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ MARKETING_SITE_BASE_URL='http://localhost:18000'
|
|||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
PORT=2000
|
PORT=2000
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
|
||||||
SEGMENT_KEY=null
|
SEGMENT_KEY=null
|
||||||
SITE_NAME='edX'
|
SITE_NAME='edX'
|
||||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ MARKETING_SITE_BASE_URL='http://localhost:18000'
|
|||||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||||
PORT=2000
|
PORT=2000
|
||||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
|
||||||
SEGMENT_KEY=null
|
SEGMENT_KEY=null
|
||||||
SITE_NAME='edX'
|
SITE_NAME='edX'
|
||||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||||
|
|||||||
@@ -21,31 +21,6 @@ For example, they discourage using class or element name selectors to find compo
|
|||||||
during a test. Instead, you should find them by user-oriented attributes like labels,
|
during a test. Instead, you should find them by user-oriented attributes like labels,
|
||||||
text, or roles. As a last resort, by a `data-testid` tag.
|
text, or roles. As a last resort, by a `data-testid` tag.
|
||||||
|
|
||||||
## Mocking data
|
|
||||||
We'll use [Rosie](https://github.com/rosiejs/rosie) as a tool for building JavaScript objects.
|
|
||||||
Our main use case for Rosie is to use factories in order to mock the data we'd like to fetch when rendering components.
|
|
||||||
[axios-mock-adapter](https://www.npmjs.com/package/axios-mock-adapter) allows us to mock the response of an HTTP request.
|
|
||||||
|
|
||||||
For example, we may use a factory to build a course metadata object:
|
|
||||||
|
|
||||||
`const courseMetadata = Factory.build('courseMetadata');`
|
|
||||||
|
|
||||||
Then we'd pass that `courseMetadata` object into an axios mock call:
|
|
||||||
|
|
||||||
`axiosMock.onGet('example.com').reply(200, courseMetadata);`
|
|
||||||
|
|
||||||
This way, when a component sends a GET request to `example.com` within the test's lifecycle, the request will be intercepted
|
|
||||||
by the axios-mock-adapter, and the courseMetadata object will be returned.
|
|
||||||
|
|
||||||
These factories should live within the data directories they intend to mock
|
|
||||||
```
|
|
||||||
courseware
|
|
||||||
| data
|
|
||||||
| __factories__
|
|
||||||
| courseMetadata.factory.js /* used to define the Rosie factory */
|
|
||||||
| api.js /* getCourseMetadata() lives here */
|
|
||||||
```
|
|
||||||
|
|
||||||
## What to Test
|
## What to Test
|
||||||
We have not found exhaustive unit testing of frontend code to be worth the trouble.
|
We have not found exhaustive unit testing of frontend code to be worth the trouble.
|
||||||
Rather, let's focus on testing non-obvious behavior.
|
Rather, let's focus on testing non-obvious behavior.
|
||||||
|
|||||||
8087
package-lock.json
generated
8087
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -35,15 +35,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||||
"@edx/frontend-component-footer": "10.1.4",
|
"@edx/frontend-component-footer": "^10.1.0",
|
||||||
"@edx/frontend-enterprise": "4.2.3",
|
"@edx/frontend-enterprise": "4.2.3",
|
||||||
"@edx/frontend-platform": "1.8.4",
|
"@edx/frontend-platform": "1.8.0",
|
||||||
"@edx/paragon": "12.3.1",
|
"@edx/paragon": "^12.3.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "1.2.34",
|
"@fortawesome/fontawesome-svg-core": "1.2.32",
|
||||||
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
"@fortawesome/free-brands-svg-icons": "5.13.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
"@fortawesome/free-regular-svg-icons": "5.13.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
"@fortawesome/free-solid-svg-icons": "5.13.1",
|
||||||
"@fortawesome/react-fontawesome": "0.1.14",
|
"@fortawesome/react-fontawesome": "0.1.13",
|
||||||
"@reduxjs/toolkit": "1.3.6",
|
"@reduxjs/toolkit": "1.3.6",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
"core-js": "3.6.5",
|
"core-js": "3.6.5",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"truncate-html": "1.0.3"
|
"truncate-html": "1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/frontend-build": "git+https://github.com/edx/frontend-build.git#alpha",
|
"@edx/frontend-build": "5.5.1",
|
||||||
"@testing-library/dom": "7.16.3",
|
"@testing-library/dom": "7.16.3",
|
||||||
"@testing-library/jest-dom": "5.10.1",
|
"@testing-library/jest-dom": "5.10.1",
|
||||||
"@testing-library/react": "10.3.0",
|
"@testing-library/react": "10.3.0",
|
||||||
|
|||||||
@@ -1,140 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
|
||||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { Hyperlink } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
function AccessExpirationAlert({ intl, payload }) {
|
function AccessExpirationAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
accessExpiration,
|
rawHtml,
|
||||||
userTimezone,
|
|
||||||
} = payload;
|
} = payload;
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
return rawHtml && (
|
||||||
|
|
||||||
if (!accessExpiration) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
expirationDate,
|
|
||||||
masqueradingExpiredCourse,
|
|
||||||
upgradeDeadline,
|
|
||||||
upgradeUrl,
|
|
||||||
} = accessExpiration;
|
|
||||||
|
|
||||||
if (masqueradingExpiredCourse) {
|
|
||||||
return (
|
|
||||||
<Alert type={ALERT_TYPES.INFO}>
|
<Alert type={ALERT_TYPES.INFO}>
|
||||||
<FormattedMessage
|
{/* eslint-disable-next-line react/no-danger */}
|
||||||
id="learning.accessExpiration.expired"
|
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||||
defaultMessage="This learner does not have access to this course. Their access expired on {date}."
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessExpirationExpiredDate"
|
|
||||||
day="numeric"
|
|
||||||
month="short"
|
|
||||||
year="numeric"
|
|
||||||
value={expirationDate}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let deadlineMessage = null;
|
|
||||||
if (upgradeDeadline && upgradeUrl) {
|
|
||||||
deadlineMessage = (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.accessExpiration.deadline"
|
|
||||||
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessExpirationUpgradeDeadline"
|
|
||||||
day="numeric"
|
|
||||||
month="short"
|
|
||||||
year="numeric"
|
|
||||||
value={upgradeDeadline}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Hyperlink
|
|
||||||
className="font-weight-bold"
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
destination={upgradeUrl}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.upgradeNow)}
|
|
||||||
</Hyperlink>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert type={ALERT_TYPES.INFO}>
|
|
||||||
<span className="font-weight-bold">
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.accessExpiration.header"
|
|
||||||
defaultMessage="Audit Access Expires {date}"
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessExpirationHeaderDate"
|
|
||||||
day="numeric"
|
|
||||||
month="short"
|
|
||||||
year="numeric"
|
|
||||||
value={expirationDate}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.accessExpiration.body"
|
|
||||||
defaultMessage="You lose all access to this course, including your progress, on {date}."
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="accessExpirationBodyDate"
|
|
||||||
day="numeric"
|
|
||||||
month="short"
|
|
||||||
year="numeric"
|
|
||||||
value={expirationDate}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{deadlineMessage}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessExpirationAlert.propTypes = {
|
AccessExpirationAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
accessExpiration: PropTypes.shape({
|
rawHtml: PropTypes.string.isRequired,
|
||||||
expirationDate: PropTypes.string.isRequired,
|
|
||||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
|
||||||
upgradeDeadline: PropTypes.string,
|
|
||||||
upgradeUrl: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
userTimezone: PropTypes.string.isRequired,
|
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AccessExpirationAlert);
|
export default AccessExpirationAlert;
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ import { useAlert } from '../../generic/user-messages';
|
|||||||
|
|
||||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||||
|
|
||||||
function useAccessExpirationAlert(accessExpiration, userTimezone, topic) {
|
function useAccessExpirationAlert(courseExpiredMessage, topic) {
|
||||||
const isVisible = !!accessExpiration; // If it exists, show it.
|
const rawHtml = courseExpiredMessage || null;
|
||||||
const payload = {
|
const isVisible = !!rawHtml; // If it exists, show it.
|
||||||
accessExpiration,
|
|
||||||
userTimezone,
|
|
||||||
};
|
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientAccessExpirationAlert',
|
code: 'clientAccessExpirationAlert',
|
||||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
|
||||||
topic,
|
topic,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
upgradeNow: {
|
|
||||||
id: 'learning.accessExpiration.upgradeNow',
|
|
||||||
defaultMessage: 'Upgrade now',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -5,7 +5,6 @@ import { Button } from '@edx/paragon';
|
|||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useModel } from '../../generic/model-store';
|
|
||||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -19,13 +18,8 @@ function EnrollmentAlert({ intl, payload }) {
|
|||||||
isStaff,
|
isStaff,
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
const {
|
|
||||||
org,
|
|
||||||
} = useModel('courseHomeMeta', courseId);
|
|
||||||
|
|
||||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||||
courseId,
|
courseId,
|
||||||
org,
|
|
||||||
intl.formatMessage(messages.success),
|
intl.formatMessage(messages.success),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -39,8 +33,8 @@ function EnrollmentAlert({ intl, payload }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const button = canEnroll && (
|
const button = canEnroll && (
|
||||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
|
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||||
{intl.formatMessage(messages.enrollNowSentence)}
|
{intl.formatMessage(messages.enroll)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useContext, useState, useCallback, useMemo,
|
useContext, useState, useCallback, useMemo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
|
||||||
|
|
||||||
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
import { UserMessagesContext, ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
@@ -13,23 +11,14 @@ import { postCourseEnrollment } from './data/api';
|
|||||||
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
||||||
|
|
||||||
export function useEnrollmentAlert(courseId) {
|
export function useEnrollmentAlert(courseId) {
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
const course = useModel('courses', courseId);
|
||||||
const course = useModel('courseHomeMeta', courseId);
|
|
||||||
const outline = useModel('outline', courseId);
|
const outline = useModel('outline', courseId);
|
||||||
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
|
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
|
||||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
|
||||||
/**
|
|
||||||
* This alert should render if
|
|
||||||
* 1. the user is not enrolled,
|
|
||||||
* 2. the user is authenticated, AND
|
|
||||||
* 3. the course is private.
|
|
||||||
*/
|
|
||||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
canEnroll: outline.enrollAlert.canEnroll,
|
||||||
courseId,
|
courseId,
|
||||||
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
extraText: outline.enrollAlert.extraText,
|
||||||
isStaff: course && course.isStaff,
|
isStaff: course.isStaff,
|
||||||
};
|
};
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
@@ -41,7 +30,7 @@ export function useEnrollmentAlert(courseId) {
|
|||||||
return { clientEnrollmentAlert: EnrollmentAlert };
|
return { clientEnrollmentAlert: EnrollmentAlert };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEnrollClickHandler(courseId, orgId, successText) {
|
export function useEnrollClickHandler(courseId, successText) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { addFlash } = useContext(UserMessagesContext);
|
const { addFlash } = useContext(UserMessagesContext);
|
||||||
const enrollClickHandler = useCallback(() => {
|
const enrollClickHandler = useCallback(() => {
|
||||||
@@ -55,10 +44,6 @@ export function useEnrollClickHandler(courseId, orgId, successText) {
|
|||||||
topic: 'course',
|
topic: 'course',
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
sendTrackEvent('edx.bi.user.course-home.enrollment', {
|
|
||||||
org_key: orgId,
|
|
||||||
courserun_key: courseId,
|
|
||||||
});
|
|
||||||
global.location.reload();
|
global.location.reload();
|
||||||
});
|
});
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|||||||
@@ -11,15 +11,9 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||||
},
|
},
|
||||||
enrollNowInline: {
|
enroll: {
|
||||||
id: 'learning.enrollment.enrollNow.Inline',
|
id: 'learning.enrollment.enroll.now',
|
||||||
defaultMessage: 'Enroll now',
|
defaultMessage: 'Enroll Now',
|
||||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.'
|
|
||||||
+ 'This text is meant to be used at the beginning of a sentence (example: Enroll now to view course content.)',
|
|
||||||
},
|
|
||||||
enrollNowSentence: {
|
|
||||||
id: 'learning.enrollment.enrollNow.Sentence',
|
|
||||||
defaultMessage: 'Enroll now.',
|
|
||||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
|
|||||||
@@ -2,30 +2,23 @@ import React from 'react';
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||||
import { Hyperlink } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { Alert } from '../../generic/user-messages';
|
import { Alert } from '../../generic/user-messages';
|
||||||
import genericMessages from '../../generic/messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function LogistrationAlert({ intl }) {
|
function LogistrationAlert({ intl }) {
|
||||||
const signIn = (
|
const signIn = (
|
||||||
<Hyperlink
|
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
|
||||||
style={{ textDecoration: 'underline' }}
|
{intl.formatMessage(messages.login)}
|
||||||
destination={`${getLoginRedirectUrl(global.location.href)}`}
|
</a>
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.signInLowercase)}
|
|
||||||
</Hyperlink>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Pull this registration URL building out into a function, like the login one above.
|
// TODO: Pull this registration URL building out into a function, like the login one above.
|
||||||
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
|
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
|
||||||
const register = (
|
const register = (
|
||||||
<Hyperlink
|
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
|
||||||
style={{ textDecoration: 'underline' }}
|
{intl.formatMessage(messages.register)}
|
||||||
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
</a>
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.registerLowercase)}
|
|
||||||
</Hyperlink>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +26,7 @@ function LogistrationAlert({ intl }) {
|
|||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="learning.logistration.alert"
|
id="learning.logistration.alert"
|
||||||
description="Prompts the user to sign in or register to see course content."
|
description="Prompts the user to sign in or register to see course content."
|
||||||
defaultMessage="To see course content, {signIn} or {register}."
|
defaultMessage="Please {signIn} or {register} to see course content."
|
||||||
values={{
|
values={{
|
||||||
signIn,
|
signIn,
|
||||||
register,
|
register,
|
||||||
|
|||||||
@@ -2,20 +2,12 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||||
import { useModel } from '../../generic/model-store';
|
|
||||||
|
|
||||||
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
|
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
|
||||||
|
|
||||||
export function useLogistrationAlert(courseId) {
|
export function useLogistrationAlert() {
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
const outline = useModel('outline', courseId);
|
const isVisible = authenticatedUser === null;
|
||||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
|
||||||
/**
|
|
||||||
* This alert should render if
|
|
||||||
* 1. the user is not authenticated, AND
|
|
||||||
* 2. the course is private.
|
|
||||||
*/
|
|
||||||
const isVisible = authenticatedUser === null && privateOutline;
|
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientLogistrationAlert',
|
code: 'clientLogistrationAlert',
|
||||||
|
|||||||
16
src/alerts/logistration-alert/messages.js
Normal file
16
src/alerts/logistration-alert/messages.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
login: {
|
||||||
|
id: 'learning.logistration.login',
|
||||||
|
defaultMessage: 'sign in',
|
||||||
|
description: 'Text in a link, prompting the user to log in. Used in "learning.logistration.alert"',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
id: 'learning.logistration.register',
|
||||||
|
defaultMessage: 'register',
|
||||||
|
description: 'Text in a link, prompting the user to create an account. Used in "learning.logistration.alert"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
@@ -1,87 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {
|
|
||||||
FormattedMessage, FormattedDate, injectIntl, intlShape,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { Hyperlink } from '@edx/paragon';
|
|
||||||
|
|
||||||
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
|
||||||
import { FormattedPricing } from '../../generic/upgrade-button';
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
function OfferAlert({ intl, payload }) {
|
function OfferAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
offer,
|
rawHtml,
|
||||||
userTimezone,
|
|
||||||
} = payload;
|
} = payload;
|
||||||
|
return rawHtml && (
|
||||||
if (!offer) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
code,
|
|
||||||
expirationDate,
|
|
||||||
percentage,
|
|
||||||
upgradeUrl,
|
|
||||||
} = offer;
|
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert type={ALERT_TYPES.INFO}>
|
<Alert type={ALERT_TYPES.INFO}>
|
||||||
{/* the first-purchase-offer-banner class can be removed post REV-1512 experiment */}
|
{/* eslint-disable-next-line react/no-danger */}
|
||||||
<span className="font-weight-bold first-purchase-offer-banner">
|
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||||
<FormattedMessage
|
|
||||||
id="learning.offer.header"
|
|
||||||
defaultMessage="Upgrade by {date} and save {percentage}% [{fullPricing}]"
|
|
||||||
values={{
|
|
||||||
date: (
|
|
||||||
<FormattedDate
|
|
||||||
key="offerDate"
|
|
||||||
day="numeric"
|
|
||||||
month="long"
|
|
||||||
value={expirationDate}
|
|
||||||
{...timezoneFormatArgs}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
fullPricing: <FormattedPricing offer={offer} />,
|
|
||||||
percentage,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.offer.code"
|
|
||||||
defaultMessage="Use code {code} at checkout!"
|
|
||||||
values={{
|
|
||||||
code: (<b>{code}</b>),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Hyperlink
|
|
||||||
className="font-weight-bold"
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
destination={upgradeUrl}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.upgradeNow)}
|
|
||||||
</Hyperlink>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
OfferAlert.propTypes = {
|
OfferAlert.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
offer: PropTypes.shape({
|
rawHtml: PropTypes.string.isRequired,
|
||||||
code: PropTypes.string.isRequired,
|
|
||||||
discountedPrice: PropTypes.string.isRequired,
|
|
||||||
expirationDate: PropTypes.string.isRequired,
|
|
||||||
originalPrice: PropTypes.string.isRequired,
|
|
||||||
percentage: PropTypes.number.isRequired,
|
|
||||||
upgradeUrl: PropTypes.string.isRequired,
|
|
||||||
}).isRequired,
|
|
||||||
userTimezone: PropTypes.string.isRequired,
|
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(OfferAlert);
|
export default OfferAlert;
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import { useAlert } from '../../generic/user-messages';
|
|||||||
|
|
||||||
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
const OfferAlert = React.lazy(() => import('./OfferAlert'));
|
||||||
|
|
||||||
export function useOfferAlert(offer, userTimezone, topic) {
|
export function useOfferAlert(offerHtml, topic) {
|
||||||
const isVisible = !!offer; // if it exists, show it.
|
const rawHtml = offerHtml || null;
|
||||||
const payload = {
|
const isVisible = !!rawHtml; // if it exists, show it.
|
||||||
offer,
|
|
||||||
userTimezone,
|
|
||||||
};
|
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientOfferAlert',
|
code: 'clientOfferAlert',
|
||||||
topic,
|
topic,
|
||||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { clientOfferAlert: OfferAlert };
|
return { clientOfferAlert: OfferAlert };
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
upgradeNow: {
|
|
||||||
id: 'learning.offer.upgradeNow',
|
|
||||||
defaultMessage: 'Upgrade now',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Button } from '@edx/paragon';
|
|
||||||
|
|
||||||
import genericMessages from '../generic/messages';
|
|
||||||
|
|
||||||
function AnonymousUserMenu({ intl }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
className="mr-3"
|
|
||||||
variant="outline-primary"
|
|
||||||
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.registerSentenceCase)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
href={`${getLoginRedirectUrl(global.location.href)}`}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.signInSentenceCase)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AnonymousUserMenu.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(AnonymousUserMenu);
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Dropdown } from '@edx/paragon';
|
|
||||||
|
|
||||||
import messages from './messages';
|
|
||||||
|
|
||||||
function AuthenticatedUserDropdown({ enterpriseLearnerPortalLink, intl, username }) {
|
|
||||||
let dashboardMenuItem = (
|
|
||||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
|
||||||
{intl.formatMessage(messages.dashboard)}
|
|
||||||
</Dropdown.Item>
|
|
||||||
);
|
|
||||||
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
|
|
||||||
dashboardMenuItem = (
|
|
||||||
<Dropdown.Item
|
|
||||||
href={enterpriseLearnerPortalLink.href}
|
|
||||||
>
|
|
||||||
{enterpriseLearnerPortalLink.content}
|
|
||||||
</Dropdown.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
|
|
||||||
<Dropdown className="user-dropdown">
|
|
||||||
<Dropdown.Toggle variant="outline-primary">
|
|
||||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
|
||||||
<span data-hj-suppress className="d-none d-md-inline">
|
|
||||||
{username}
|
|
||||||
</span>
|
|
||||||
</Dropdown.Toggle>
|
|
||||||
<Dropdown.Menu className="dropdown-menu-right">
|
|
||||||
{dashboardMenuItem}
|
|
||||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${username}`}>
|
|
||||||
{intl.formatMessage(messages.profile)}
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
|
|
||||||
{intl.formatMessage(messages.account)}
|
|
||||||
</Dropdown.Item>
|
|
||||||
{!enterpriseLearnerPortalLink && (
|
|
||||||
// Users should only see Order History if they do not have an available
|
|
||||||
// learner portal, because an available learner portal currently means
|
|
||||||
// that they access content via Subscriptions, in which context an "order"
|
|
||||||
// is not relevant.
|
|
||||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
|
|
||||||
{intl.formatMessage(messages.orderHistory)}
|
|
||||||
</Dropdown.Item>
|
|
||||||
)}
|
|
||||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>
|
|
||||||
{intl.formatMessage(messages.signOut)}
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthenticatedUserDropdown.propTypes = {
|
|
||||||
enterpriseLearnerPortalLink: PropTypes.string,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
username: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
AuthenticatedUserDropdown.defaultProps = {
|
|
||||||
enterpriseLearnerPortalLink: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(AuthenticatedUserDropdown);
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Dropdown } from '@edx/paragon';
|
||||||
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
|
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
|
|
||||||
import AnonymousUserMenu from './AnonymousUserMenu';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
|
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function LinkedLogo({
|
function LinkedLogo({
|
||||||
@@ -39,6 +41,21 @@ function Header({
|
|||||||
getConfig().LMS_BASE_URL,
|
getConfig().LMS_BASE_URL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let dashboardMenuItem = (
|
||||||
|
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||||
|
{intl.formatMessage(messages.dashboard)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
);
|
||||||
|
if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) {
|
||||||
|
dashboardMenuItem = (
|
||||||
|
<Dropdown.Item
|
||||||
|
href={enterpriseLearnerPortalLink.href}
|
||||||
|
>
|
||||||
|
{enterpriseLearnerPortalLink.content}
|
||||||
|
</Dropdown.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let headerLogo = (
|
let headerLogo = (
|
||||||
<LinkedLogo
|
<LinkedLogo
|
||||||
className="logo"
|
className="logo"
|
||||||
@@ -60,22 +77,42 @@ function Header({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="course-header">
|
<header className="course-header">
|
||||||
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
|
|
||||||
<div className="container-fluid py-2 d-flex align-items-center">
|
<div className="container-fluid py-2 d-flex align-items-center">
|
||||||
{headerLogo}
|
{headerLogo}
|
||||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
{authenticatedUser && (
|
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
|
||||||
<AuthenticatedUserDropdown
|
<Dropdown className="user-dropdown">
|
||||||
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
|
<Dropdown.Toggle variant="outline-primary">
|
||||||
username={authenticatedUser.username}
|
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||||
/>
|
<span data-hj-suppress className="d-none d-md-inline">
|
||||||
)}
|
{authenticatedUser.username}
|
||||||
{!authenticatedUser && (
|
</span>
|
||||||
<AnonymousUserMenu />
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="dropdown-menu-right">
|
||||||
|
{dashboardMenuItem}
|
||||||
|
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>
|
||||||
|
{intl.formatMessage(messages.profile)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>
|
||||||
|
{intl.formatMessage(messages.account)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
{!enterpriseLearnerPortalLink && (
|
||||||
|
// Users should only see Order History if they do not have an available
|
||||||
|
// learner portal, because an available learner portal currently means
|
||||||
|
// that they access content via Subscriptions, in which context an "order"
|
||||||
|
// is not relevant.
|
||||||
|
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
|
||||||
|
{intl.formatMessage(messages.orderHistory)}
|
||||||
|
</Dropdown.Item>
|
||||||
)}
|
)}
|
||||||
|
<Dropdown.Item href={getConfig().LOGOUT_URL}>
|
||||||
|
{intl.formatMessage(messages.signOut)}
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Order History',
|
defaultMessage: 'Order History',
|
||||||
description: 'The text for the user menu Order History navigation link.',
|
description: 'The text for the user menu Order History navigation link.',
|
||||||
},
|
},
|
||||||
skipNavLink: {
|
|
||||||
id: 'header.navigation.skipNavLink',
|
|
||||||
defaultMessage: 'Skip to main content.',
|
|
||||||
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
|
|
||||||
},
|
|
||||||
signOut: {
|
signOut: {
|
||||||
id: 'header.menu.signOut.label',
|
id: 'header.menu.signOut.label',
|
||||||
defaultMessage: 'Sign Out',
|
defaultMessage: 'Sign Out',
|
||||||
|
|||||||
@@ -67,16 +67,6 @@ Factory.define('courseHomeMetadata')
|
|||||||
},
|
},
|
||||||
{ courseId, path: 'instructor' },
|
{ courseId, path: 'instructor' },
|
||||||
),
|
),
|
||||||
Factory.build(
|
|
||||||
'tab',
|
|
||||||
{
|
|
||||||
title: 'Dates',
|
|
||||||
priority: 5,
|
|
||||||
slug: 'dates',
|
|
||||||
type: 'dates',
|
|
||||||
},
|
|
||||||
{ courseId, path: 'dates' },
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return tabs.map(
|
return tabs.map(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Factory.define('datesTabData')
|
|||||||
content_type_gating_enabled: false,
|
content_type_gating_enabled: false,
|
||||||
missed_gated_content: false,
|
missed_gated_content: false,
|
||||||
missed_deadlines: false,
|
missed_deadlines: false,
|
||||||
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
|
||||||
},
|
},
|
||||||
course_date_blocks: [
|
course_date_blocks: [
|
||||||
{
|
{
|
||||||
@@ -217,7 +216,9 @@ Factory.define('datesTabData')
|
|||||||
extra_info: null,
|
extra_info: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
has_ended: false,
|
missed_deadlines: false,
|
||||||
|
missed_gated_content: false,
|
||||||
learner_is_full_access: true,
|
learner_is_full_access: true,
|
||||||
user_timezone: 'America/New_York',
|
user_timezone: 'America/New_York',
|
||||||
|
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ Factory.define('outlineTabData')
|
|||||||
upgrade_url: `${host}/dashboard`,
|
upgrade_url: `${host}/dashboard`,
|
||||||
}))
|
}))
|
||||||
.attrs({
|
.attrs({
|
||||||
access_expiration: null,
|
|
||||||
can_show_upgrade_sock: true,
|
can_show_upgrade_sock: true,
|
||||||
|
course_expired_html: null,
|
||||||
course_goals: {
|
course_goals: {
|
||||||
goal_options: [],
|
goal_options: [],
|
||||||
selected_goal: null,
|
selected_goal: null,
|
||||||
@@ -50,6 +50,6 @@ Factory.define('outlineTabData')
|
|||||||
extra_text: 'Contact the administrator.',
|
extra_text: 'Contact the administrator.',
|
||||||
},
|
},
|
||||||
handouts_html: '<ul><li>Handout 1</li></ul>',
|
handouts_html: '<ul><li>Handout 1</li></ul>',
|
||||||
offer: null,
|
offer_html: null,
|
||||||
welcome_message_html: '<p>Welcome to this course!</p>',
|
welcome_message_html: '<p>Welcome to this course!</p>',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Object {
|
|||||||
"sequenceStatus": "loading",
|
"sequenceStatus": "loading",
|
||||||
},
|
},
|
||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courses": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||||
@@ -28,7 +28,7 @@ Object {
|
|||||||
"originalUserIsStaff": false,
|
"originalUserIsStaff": false,
|
||||||
"tabs": Array [
|
"tabs": Array [
|
||||||
Object {
|
Object {
|
||||||
"slug": "outline",
|
"slug": "courseware",
|
||||||
"title": "Course",
|
"title": "Course",
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||||
},
|
},
|
||||||
@@ -52,11 +52,6 @@ Object {
|
|||||||
"title": "Instructor",
|
"title": "Instructor",
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"slug": "dates",
|
|
||||||
"title": "Dates",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"title": "Demonstration Course",
|
"title": "Demonstration Course",
|
||||||
},
|
},
|
||||||
@@ -273,12 +268,13 @@ Object {
|
|||||||
"contentTypeGatingEnabled": false,
|
"contentTypeGatingEnabled": false,
|
||||||
"missedDeadlines": false,
|
"missedDeadlines": false,
|
||||||
"missedGatedContent": false,
|
"missedGatedContent": false,
|
||||||
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
|
||||||
},
|
},
|
||||||
"hasEnded": false,
|
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||||
"learnerIsFullAccess": true,
|
"learnerIsFullAccess": true,
|
||||||
|
"missedDeadlines": false,
|
||||||
|
"missedGatedContent": false,
|
||||||
"userTimezone": "America/New_York",
|
"userTimezone": "America/New_York",
|
||||||
|
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -301,7 +297,7 @@ Object {
|
|||||||
"sequenceStatus": "loading",
|
"sequenceStatus": "loading",
|
||||||
},
|
},
|
||||||
"models": Object {
|
"models": Object {
|
||||||
"courseHomeMeta": Object {
|
"courses": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||||
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||||
@@ -313,7 +309,7 @@ Object {
|
|||||||
"originalUserIsStaff": false,
|
"originalUserIsStaff": false,
|
||||||
"tabs": Array [
|
"tabs": Array [
|
||||||
Object {
|
Object {
|
||||||
"slug": "outline",
|
"slug": "courseware",
|
||||||
"title": "Course",
|
"title": "Course",
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
|
||||||
},
|
},
|
||||||
@@ -337,18 +333,12 @@ Object {
|
|||||||
"title": "Instructor",
|
"title": "Instructor",
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
|
||||||
},
|
},
|
||||||
Object {
|
|
||||||
"slug": "dates",
|
|
||||||
"title": "Dates",
|
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/dates",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"title": "Demonstration Course",
|
"title": "Demonstration Course",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"outline": Object {
|
"outline": Object {
|
||||||
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
"course-v1:edX+DemoX+Demo_Course_1": Object {
|
||||||
"accessExpiration": null,
|
|
||||||
"canShowUpgradeSock": true,
|
"canShowUpgradeSock": true,
|
||||||
"courseBlocks": Object {
|
"courseBlocks": Object {
|
||||||
"courses": Object {
|
"courses": Object {
|
||||||
@@ -385,6 +375,7 @@ Object {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"courseExpiredHtml": null,
|
||||||
"courseGoals": Object {
|
"courseGoals": Object {
|
||||||
"goalOptions": Array [],
|
"goalOptions": Array [],
|
||||||
"selectedGoal": null,
|
"selectedGoal": null,
|
||||||
@@ -412,7 +403,7 @@ Object {
|
|||||||
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
|
||||||
"hasEnded": undefined,
|
"hasEnded": undefined,
|
||||||
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
"id": "course-v1:edX+DemoX+Demo_Course_1",
|
||||||
"offer": null,
|
"offerHtml": null,
|
||||||
"resumeCourse": Object {
|
"resumeCourse": Object {
|
||||||
"hasVisitedCourse": false,
|
"hasVisitedCourse": false,
|
||||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ function normalizeCourseHomeCourseMetadata(metadata) {
|
|||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
tabs: data.tabs.map(tab => ({
|
tabs: data.tabs.map(tab => ({
|
||||||
// The API uses "courseware" as a slug for both courseware and the outline tab. We switch it to "outline" here for
|
slug: tab.tabId,
|
||||||
// use within the MFE to differentiate between course home and courseware.
|
|
||||||
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
|
|
||||||
title: tab.title,
|
title: tab.title,
|
||||||
url: tab.url,
|
url: tab.url,
|
||||||
})),
|
})),
|
||||||
@@ -107,10 +105,6 @@ export async function getDatesTabData(courseId) {
|
|||||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if (httpErrorStatus === 401) {
|
|
||||||
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,20 +124,6 @@ export async function getProgressTabData(courseId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProctoringInfoData(courseId) {
|
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
|
||||||
try {
|
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
const { httpErrorStatus } = error && error.customAttributes;
|
|
||||||
if (httpErrorStatus === 404) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOutlineTabData(courseId) {
|
export async function getOutlineTabData(courseId) {
|
||||||
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||||
let { tabData } = {};
|
let { tabData } = {};
|
||||||
@@ -161,45 +141,42 @@ export async function getOutlineTabData(courseId) {
|
|||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
} = tabData;
|
} = tabData;
|
||||||
const accessExpiration = camelCaseObject(data.access_expiration);
|
|
||||||
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
const canShowUpgradeSock = data.can_show_upgrade_sock;
|
||||||
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
|
||||||
const courseGoals = camelCaseObject(data.course_goals);
|
const courseGoals = camelCaseObject(data.course_goals);
|
||||||
|
const courseExpiredHtml = data.course_expired_html;
|
||||||
const courseTools = camelCaseObject(data.course_tools);
|
const courseTools = camelCaseObject(data.course_tools);
|
||||||
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
|
||||||
const datesWidget = camelCaseObject(data.dates_widget);
|
const datesWidget = camelCaseObject(data.dates_widget);
|
||||||
const enrollAlert = camelCaseObject(data.enroll_alert);
|
const enrollAlert = camelCaseObject(data.enroll_alert);
|
||||||
const handoutsHtml = data.handouts_html;
|
const handoutsHtml = data.handouts_html;
|
||||||
const hasEnded = data.has_ended;
|
const hasEnded = data.has_ended;
|
||||||
const offer = camelCaseObject(data.offer);
|
const offerHtml = data.offer_html;
|
||||||
const resumeCourse = camelCaseObject(data.resume_course);
|
const resumeCourse = camelCaseObject(data.resume_course);
|
||||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||||
const welcomeMessageHtml = data.welcome_message_html;
|
const welcomeMessageHtml = data.welcome_message_html;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessExpiration,
|
|
||||||
canShowUpgradeSock,
|
canShowUpgradeSock,
|
||||||
courseBlocks,
|
courseBlocks,
|
||||||
courseGoals,
|
courseGoals,
|
||||||
|
courseExpiredHtml,
|
||||||
courseTools,
|
courseTools,
|
||||||
datesBannerInfo,
|
datesBannerInfo,
|
||||||
datesWidget,
|
datesWidget,
|
||||||
enrollAlert,
|
enrollAlert,
|
||||||
handoutsHtml,
|
handoutsHtml,
|
||||||
hasEnded,
|
hasEnded,
|
||||||
offer,
|
offerHtml,
|
||||||
resumeCourse,
|
resumeCourse,
|
||||||
verifiedMode,
|
verifiedMode,
|
||||||
welcomeMessageHtml,
|
welcomeMessageHtml,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postCourseDeadlines(courseId, model) {
|
export async function postCourseDeadlines(courseId) {
|
||||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
|
||||||
return getAuthenticatedHttpClient().post(url.href, {
|
return getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
|
||||||
course_key: courseId,
|
|
||||||
research_event_data: { location: `${model}-tab` },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postCourseGoals(courseId, goalKey) {
|
export async function postCourseGoals(courseId, goalKey) {
|
||||||
@@ -217,10 +194,7 @@ export async function postRequestCert(courseId) {
|
|||||||
await getAuthenticatedHttpClient().post(url.href);
|
await getAuthenticatedHttpClient().post(url.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executePostFromPostEvent(postData, researchEventData) {
|
export async function executePostFromPostEvent(postData) {
|
||||||
const url = new URL(postData.url);
|
const url = new URL(postData.url);
|
||||||
return getAuthenticatedHttpClient().post(url.href, {
|
return getAuthenticatedHttpClient().post(url.href, { course_key: postData.bodyParams.courseId });
|
||||||
course_key: postData.bodyParams.courseId,
|
|
||||||
research_event_data: researchEventData,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,17 +102,16 @@ describe('Data layer integration tests', () => {
|
|||||||
describe('Test resetDeadlines', () => {
|
describe('Test resetDeadlines', () => {
|
||||||
it('Should reset course deadlines', async () => {
|
it('Should reset course deadlines', async () => {
|
||||||
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
|
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
|
||||||
const model = 'dates';
|
|
||||||
axiosMock.onPost(resetUrl).reply(201, {});
|
axiosMock.onPost(resetUrl).reply(201, {});
|
||||||
|
|
||||||
const getTabDataMock = jest.fn(() => ({
|
const getTabDataMock = jest.fn(() => ({
|
||||||
type: 'MOCK_ACTION',
|
type: 'MOCK_ACTION',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await executeThunk(thunks.resetDeadlines(courseId, model, getTabDataMock), store.dispatch);
|
await executeThunk(thunks.resetDeadlines(courseId, getTabDataMock), store.dispatch);
|
||||||
|
|
||||||
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
|
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
|
||||||
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}","research_event_data":{"location":"dates-tab"}}`);
|
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}"}`);
|
||||||
|
|
||||||
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
|
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function fetchTab(courseId, tab, getTabData) {
|
|||||||
|
|
||||||
if (fetchedCourseHomeCourseMetadata) {
|
if (fetchedCourseHomeCourseMetadata) {
|
||||||
dispatch(addModel({
|
dispatch(addModel({
|
||||||
modelType: 'courseHomeMeta',
|
modelType: 'courses',
|
||||||
model: {
|
model: {
|
||||||
id: courseId,
|
id: courseId,
|
||||||
...courseHomeCourseMetadataResult.value,
|
...courseHomeCourseMetadataResult.value,
|
||||||
@@ -90,9 +90,9 @@ export function requestCert(courseId) {
|
|||||||
return async () => postRequestCert(courseId);
|
return async () => postRequestCert(courseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetDeadlines(courseId, model, getTabData) {
|
export function resetDeadlines(courseId, getTabData) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
postCourseDeadlines(courseId, model).then(response => {
|
postCourseDeadlines(courseId).then(response => {
|
||||||
const { data } = response;
|
const { data } = response;
|
||||||
const {
|
const {
|
||||||
header,
|
header,
|
||||||
@@ -111,12 +111,9 @@ export async function saveCourseGoal(courseId, goalKey) {
|
|||||||
|
|
||||||
export function processEvent(eventData, getTabData) {
|
export function processEvent(eventData, getTabData) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
// Pulling this out early so the data doesn't get camelCased and is easier
|
|
||||||
// to use when it's passed to the backend
|
|
||||||
const { research_event_data: researchEventData } = eventData;
|
|
||||||
const event = camelCaseObject(eventData);
|
const event = camelCaseObject(eventData);
|
||||||
if (event.eventName === eventTypes.POST_EVENT) {
|
if (event.eventName === eventTypes.POST_EVENT) {
|
||||||
executePostFromPostEvent(event.postData, researchEventData).then(response => {
|
executePostFromPostEvent(event.postData).then(response => {
|
||||||
const { data } = response;
|
const { data } = response;
|
||||||
const {
|
const {
|
||||||
header,
|
header,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function DatesBannerContainer({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isSelfPaced,
|
isSelfPaced,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courses', courseId);
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
|
||||||
@@ -54,7 +54,7 @@ function DatesBannerContainer({
|
|||||||
{
|
{
|
||||||
name: 'resetDatesBanner',
|
name: 'resetDatesBanner',
|
||||||
shouldDisplay: resetDates,
|
shouldDisplay: resetDates,
|
||||||
clickHandler: () => dispatch(resetDeadlines(courseId, model, tabFetch)),
|
clickHandler: () => dispatch(resetDeadlines(courseId, tabFetch)),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import classNames from 'classnames';
|
|||||||
export default function Badge({ children, className }) {
|
export default function Badge({ children, className }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={classNames('dates-badge small ml-2', className)}
|
className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}
|
||||||
data-testid="dates-badge"
|
data-testid="dates-badge"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
.dates-badge {
|
.dates-badge {
|
||||||
border-radius: 4px;
|
font-size: 0.75rem;
|
||||||
padding: 2px 8px 3px 8px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function DatesTab({ intl }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div role="heading" aria-level="1" className="h2 my-3">
|
<div role="heading" aria-level="1" className="h4 my-3">
|
||||||
{intl.formatMessage(messages.title)}
|
{intl.formatMessage(messages.title)}
|
||||||
</div>
|
</div>
|
||||||
<DatesBannerContainer
|
<DatesBannerContainer
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
|
|
||||||
import DatesTab from './DatesTab';
|
import DatesTab from './DatesTab';
|
||||||
import { fetchDatesTab } from '../data';
|
import { fetchDatesTab } from '../data';
|
||||||
import { fireEvent, initializeMockApp, waitFor } from '../../setupTest';
|
import { initializeMockApp } from '../../setupTest';
|
||||||
import initializeStore from '../../store';
|
import initializeStore from '../../store';
|
||||||
import { TabContainer } from '../../tab-page';
|
import { TabContainer } from '../../tab-page';
|
||||||
import { UserMessagesProvider } from '../../generic/user-messages';
|
import { UserMessagesProvider } from '../../generic/user-messages';
|
||||||
@@ -33,6 +33,14 @@ describe('DatesTab', () => {
|
|||||||
</UserMessagesProvider>
|
</UserMessagesProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
|
const courseMetadata = Factory.build('courseHomeMetadata');
|
||||||
|
const { courseId } = courseMetadata;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata);
|
||||||
|
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||||
|
});
|
||||||
|
|
||||||
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
|
// The dates tab is largely repetitive non-interactive static data. Thus it's a little tough to follow
|
||||||
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
|
// testing-library's advice around testing the way your user uses the site (i.e. can't find form elements by label or
|
||||||
@@ -59,14 +67,7 @@ describe('DatesTab', () => {
|
|||||||
describe('when receiving a full set of dates data', () => {
|
describe('when receiving a full set of dates data', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const datesTabData = Factory.build('datesTabData');
|
const datesTabData = Factory.build('datesTabData');
|
||||||
const courseMetadata = Factory.build('courseHomeMetadata');
|
|
||||||
const { courseId } = courseMetadata;
|
|
||||||
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata);
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
||||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
|
||||||
|
|
||||||
render(component);
|
render(component);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,21 +76,21 @@ describe('DatesTab', () => {
|
|||||||
const badges = within(header).getAllByTestId('dates-badge');
|
const badges = within(header).getAllByTestId('dates-badge');
|
||||||
expect(badges).toHaveLength(2);
|
expect(badges).toHaveLength(2);
|
||||||
expect(badges[0]).toHaveTextContent('Completed');
|
expect(badges[0]).toHaveTextContent('Completed');
|
||||||
expect(badges[1]).toHaveTextContent('Not yet released');
|
expect(badges[1]).toHaveTextContent('Not Yet Released');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles unreleased & past due', async () => {
|
it('handles unreleased & past due', async () => {
|
||||||
const { header } = await getDay('Mon, May 4, 2020');
|
const { header } = await getDay('Mon, May 4, 2020');
|
||||||
const badges = within(header).getAllByTestId('dates-badge');
|
const badges = within(header).getAllByTestId('dates-badge');
|
||||||
expect(badges).toHaveLength(2);
|
expect(badges).toHaveLength(2);
|
||||||
expect(badges[0]).toHaveTextContent('Past due');
|
expect(badges[0]).toHaveTextContent('Past Due');
|
||||||
expect(badges[1]).toHaveTextContent('Not yet released');
|
expect(badges[1]).toHaveTextContent('Not Yet Released');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles verified only', async () => {
|
it('handles verified only', async () => {
|
||||||
const { day } = await getDay('Sun, Aug 18, 2030');
|
const { day } = await getDay('Sun, Aug 18, 2030');
|
||||||
const badge = within(day).getByTestId('dates-badge');
|
const badge = within(day).getByTestId('dates-badge');
|
||||||
expect(badge).toHaveTextContent('Verified only');
|
expect(badge).toHaveTextContent('Verified Only');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verified only has no link', async () => {
|
it('verified only has no link', async () => {
|
||||||
@@ -100,7 +101,7 @@ describe('DatesTab', () => {
|
|||||||
it('same status items have header badge', async () => {
|
it('same status items have header badge', async () => {
|
||||||
const { day, header } = await getDay('Tue, May 26, 2020');
|
const { day, header } = await getDay('Tue, May 26, 2020');
|
||||||
const badge = within(header).getByTestId('dates-badge');
|
const badge = within(header).getByTestId('dates-badge');
|
||||||
expect(badge).toHaveTextContent('Past due'); // one header badge
|
expect(badge).toHaveTextContent('Past Due'); // one header badge
|
||||||
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
|
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ describe('DatesTab', () => {
|
|||||||
expect(headerBadges).toHaveLength(0); // no header badges
|
expect(headerBadges).toHaveLength(0); // no header badges
|
||||||
expect(items).toHaveLength(2);
|
expect(items).toHaveLength(2);
|
||||||
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
|
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
|
||||||
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past due');
|
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past Due');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows extra info', async () => {
|
it('shows extra info', async () => {
|
||||||
@@ -127,113 +128,4 @@ describe('DatesTab', () => {
|
|||||||
waitForElementToBeRemoved(tooltip); // and it's gone again
|
waitForElementToBeRemoved(tooltip); // and it's gone again
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Dates banner container ', () => {
|
|
||||||
const courseMetadata = Factory.build('courseHomeMetadata', { is_self_paced: true, is_enrolled: true });
|
|
||||||
const { courseId } = courseMetadata;
|
|
||||||
const datesTabData = Factory.build('datesTabData');
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata);
|
|
||||||
history.push(`/course/${courseId}/dates`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders datesTabInfoBanner', async () => {
|
|
||||||
datesTabData.datesBannerInfo = {
|
|
||||||
contentTypeGatingEnabled: false,
|
|
||||||
missedDeadlines: false,
|
|
||||||
missedGatedContent: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
|
||||||
render(component);
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("We've built a suggested schedule to help you stay on track.")).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders upgradeToCompleteGradedBanner', async () => {
|
|
||||||
datesTabData.datesBannerInfo = {
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
missedDeadlines: false,
|
|
||||||
missedGatedContent: false,
|
|
||||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
|
||||||
render(component);
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
|
|
||||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Upgrade now' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders upgradeToResetBanner', async () => {
|
|
||||||
datesTabData.datesBannerInfo = {
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
missedDeadlines: true,
|
|
||||||
missedGatedContent: true,
|
|
||||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
|
||||||
render(component);
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('You are auditing this course,')).toBeInTheDocument());
|
|
||||||
expect(screen.getByText('which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Upgrade to shift due dates' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders resetDatesBanner', async () => {
|
|
||||||
datesTabData.datesBannerInfo = {
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
missedDeadlines: true,
|
|
||||||
missedGatedContent: false,
|
|
||||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
|
||||||
render(component);
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('It looks like you missed some important deadlines based on our suggested schedule.')).toBeInTheDocument());
|
|
||||||
expect(screen.getByText('To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles shift due dates click', async () => {
|
|
||||||
datesTabData.datesBannerInfo = {
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
missedDeadlines: true,
|
|
||||||
missedGatedContent: false,
|
|
||||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
|
||||||
};
|
|
||||||
|
|
||||||
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`).reply(200, datesTabData);
|
|
||||||
render(component);
|
|
||||||
|
|
||||||
// confirm "Shift due dates" button has rendered
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Shift due dates' })).toBeInTheDocument());
|
|
||||||
|
|
||||||
// update response to reflect shifted dates
|
|
||||||
datesTabData.datesBannerInfo = {
|
|
||||||
contentTypeGatingEnabled: true,
|
|
||||||
missedDeadlines: false,
|
|
||||||
missedGatedContent: false,
|
|
||||||
verifiedUpgradeLink: 'http://localhost:18130/basket/add/?sku=8CF08E5',
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetDeadlinesData = {
|
|
||||||
header: "You've successfully shifted your dates!",
|
|
||||||
};
|
|
||||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`).reply(200, resetDeadlinesData);
|
|
||||||
|
|
||||||
// click "Shift due dates"
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Shift due dates' }));
|
|
||||||
|
|
||||||
// wait for page to reload & Toast to render
|
|
||||||
await waitFor(() => expect(screen.getByText("You've successfully shifted your dates!")).toBeInTheDocument());
|
|
||||||
// confirm "Shift due dates" button has not rendered
|
|
||||||
expect(screen.queryByRole('button', { name: 'Shift due dates' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,15 +37,13 @@ function getBadgeListAndColor(date, intl, item, items) {
|
|||||||
{
|
{
|
||||||
message: messages.today,
|
message: messages.today,
|
||||||
shownForDay: isToday,
|
shownForDay: isToday,
|
||||||
bg: 'bg-warning-300',
|
bg: 'dates-bg-today',
|
||||||
className: 'text-gray-900',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: messages.completed,
|
message: messages.completed,
|
||||||
shownForDay: assignments.length && assignments.every(isComplete),
|
shownForDay: assignments.length && assignments.every(isComplete),
|
||||||
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
|
||||||
bg: 'bg-dark-100',
|
bg: 'bg-dark-100',
|
||||||
className: 'text-gray-900',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: messages.pastDue,
|
message: messages.pastDue,
|
||||||
@@ -65,7 +63,7 @@ function getBadgeListAndColor(date, intl, item, items) {
|
|||||||
message: messages.unreleased,
|
message: messages.unreleased,
|
||||||
shownForDay: assignments.length && assignments.every(isUnreleased),
|
shownForDay: assignments.length && assignments.every(isUnreleased),
|
||||||
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
|
shownForItem: x => isLearnerAssignment(x) && isUnreleased(x),
|
||||||
className: 'border border-gray-500 text-gray-500',
|
className: 'border border-dark-200 text-gray-500 align-top',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: messages.verifiedOnly,
|
message: messages.verifiedOnly,
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
dueNext: {
|
dueNext: {
|
||||||
id: 'learning.dates.badge.dueNext',
|
id: 'learning.dates.badge.dueNext',
|
||||||
defaultMessage: 'Due next',
|
defaultMessage: 'Due Next',
|
||||||
},
|
},
|
||||||
pastDue: {
|
pastDue: {
|
||||||
id: 'learning.dates.badge.pastDue',
|
id: 'learning.dates.badge.pastDue',
|
||||||
defaultMessage: 'Past due',
|
defaultMessage: 'Past Due',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
id: 'learning.dates.title',
|
id: 'learning.dates.title',
|
||||||
defaultMessage: 'Important dates',
|
defaultMessage: 'Important Dates',
|
||||||
},
|
},
|
||||||
today: {
|
today: {
|
||||||
id: 'learning.dates.badge.today',
|
id: 'learning.dates.badge.today',
|
||||||
@@ -23,11 +23,11 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
unreleased: {
|
unreleased: {
|
||||||
id: 'learning.dates.badge.unreleased',
|
id: 'learning.dates.badge.unreleased',
|
||||||
defaultMessage: 'Not yet released',
|
defaultMessage: 'Not Yet Released',
|
||||||
},
|
},
|
||||||
verifiedOnly: {
|
verifiedOnly: {
|
||||||
id: 'learning.dates.badge.verifiedOnly',
|
id: 'learning.dates.badge.verifiedOnly',
|
||||||
defaultMessage: 'Verified only',
|
defaultMessage: 'Verified Only',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function DateSummary({
|
|||||||
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
|
||||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||||
return (
|
return (
|
||||||
<li className="container p-0 mb-3 small text-dark-500">
|
<section className="container p-0 mb-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
|
||||||
<div className="ml-1 font-weight-bold">
|
<div className="ml-1 font-weight-bold">
|
||||||
@@ -27,7 +27,7 @@ export default function DateSummary({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row ml-4 pr-2">
|
<div className="row ml-4 pl-1 pr-2">
|
||||||
<div className="date-summary-text">
|
<div className="date-summary-text">
|
||||||
{linkedTitle
|
{linkedTitle
|
||||||
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
|
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
|
||||||
@@ -39,7 +39,7 @@ export default function DateSummary({
|
|||||||
{!linkedTitle && dateBlock.link
|
{!linkedTitle && dateBlock.link
|
||||||
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
|
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
body a {
|
|
||||||
color: #00688D;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.inline-link a {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.small {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
@@ -3,22 +3,16 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
export default function LmsHtmlFragment({
|
export default function LmsHtmlFragment({ html, title, ...rest }) {
|
||||||
className,
|
|
||||||
html,
|
|
||||||
title,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
const wholePage = `
|
const wholePage = `
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
|
||||||
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
|
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
|
||||||
<link rel="stylesheet" type="text/css" href="${getConfig().BASE_URL}/src/course-home/outline-tab/LmsHtmlFragment.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body class="${className}">${html}</body>
|
<body>${html}</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const iframe = useRef(null);
|
const iframe = useRef(null);
|
||||||
function handleLoad() {
|
function handleLoad() {
|
||||||
@@ -39,12 +33,7 @@ export default function LmsHtmlFragment({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LmsHtmlFragment.defaultProps = {
|
|
||||||
className: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
LmsHtmlFragment.propTypes = {
|
LmsHtmlFragment.propTypes = {
|
||||||
className: PropTypes.string,
|
|
||||||
html: PropTypes.string.isRequired,
|
html: PropTypes.string.isRequired,
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { Button, Toast } from '@edx/paragon';
|
import { Button, Toast } from '@edx/paragon';
|
||||||
@@ -22,11 +21,11 @@ import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
|
|||||||
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
|
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
|
||||||
import useCourseEndAlert from './alerts/course-end-alert';
|
import useCourseEndAlert from './alerts/course-end-alert';
|
||||||
import useCourseStartAlert from './alerts/course-start-alert';
|
import useCourseStartAlert from './alerts/course-start-alert';
|
||||||
|
import useEnrollmentAlert from '../../alerts/enrollment-alert';
|
||||||
|
import useLogistrationAlert from '../../alerts/logistration-alert';
|
||||||
import useOfferAlert from '../../alerts/offer-alert';
|
import useOfferAlert from '../../alerts/offer-alert';
|
||||||
import usePrivateCourseAlert from './alerts/private-course-alert';
|
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
import WelcomeMessage from './widgets/WelcomeMessage';
|
import WelcomeMessage from './widgets/WelcomeMessage';
|
||||||
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
|
|
||||||
|
|
||||||
function OutlineTab({ intl }) {
|
function OutlineTab({ intl }) {
|
||||||
const {
|
const {
|
||||||
@@ -34,12 +33,16 @@ function OutlineTab({ intl }) {
|
|||||||
} = useSelector(state => state.courseHome);
|
} = useSelector(state => state.courseHome);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
org,
|
|
||||||
title,
|
title,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
start,
|
||||||
|
end,
|
||||||
|
enrollmentStart,
|
||||||
|
enrollmentEnd,
|
||||||
|
enrollmentMode,
|
||||||
|
isEnrolled,
|
||||||
|
} = useModel('courses', courseId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accessExpiration,
|
|
||||||
canShowUpgradeSock,
|
canShowUpgradeSock,
|
||||||
courseBlocks: {
|
courseBlocks: {
|
||||||
courses,
|
courses,
|
||||||
@@ -49,17 +52,17 @@ function OutlineTab({ intl }) {
|
|||||||
goalOptions,
|
goalOptions,
|
||||||
selectedGoal,
|
selectedGoal,
|
||||||
},
|
},
|
||||||
|
courseExpiredHtml,
|
||||||
datesBannerInfo,
|
datesBannerInfo,
|
||||||
datesWidget: {
|
datesWidget: {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
userTimezone,
|
|
||||||
},
|
},
|
||||||
hasEnded,
|
hasEnded,
|
||||||
resumeCourse: {
|
resumeCourse: {
|
||||||
hasVisitedCourse,
|
hasVisitedCourse,
|
||||||
url: resumeCourseUrl,
|
url: resumeCourseUrl,
|
||||||
},
|
},
|
||||||
offer,
|
offerHtml,
|
||||||
verifiedMode,
|
verifiedMode,
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
|
|
||||||
@@ -67,22 +70,16 @@ function OutlineTab({ intl }) {
|
|||||||
const [goalToastHeader, setGoalToastHeader] = useState('');
|
const [goalToastHeader, setGoalToastHeader] = useState('');
|
||||||
const [expandAll, setExpandAll] = useState(false);
|
const [expandAll, setExpandAll] = useState(false);
|
||||||
|
|
||||||
const logResumeCourseClick = () => {
|
// Above the tab alerts (appearing in the order listed here)
|
||||||
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
|
const logistrationAlert = useLogistrationAlert();
|
||||||
courserun_key: courseId,
|
const enrollmentAlert = useEnrollmentAlert(courseId);
|
||||||
event_type: hasVisitedCourse ? 'resume' : 'start',
|
|
||||||
org_key: org,
|
|
||||||
url: resumeCourseUrl,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Below the course title alerts (appearing in the order listed here)
|
// Below the course title alerts (appearing in the order listed here)
|
||||||
const offerAlert = useOfferAlert(offer, userTimezone, 'outline-course-alerts');
|
const offerAlert = useOfferAlert(offerHtml, 'outline-course-alerts');
|
||||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'outline-course-alerts');
|
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredHtml, 'outline-course-alerts');
|
||||||
const courseStartAlert = useCourseStartAlert(courseId);
|
const courseStartAlert = useCourseStartAlert(courseId);
|
||||||
const courseEndAlert = useCourseEndAlert(courseId);
|
const courseEndAlert = useCourseEndAlert(courseId);
|
||||||
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
|
||||||
const privateCourseAlert = usePrivateCourseAlert(courseId);
|
|
||||||
|
|
||||||
const rootCourseId = courses && Object.keys(courses)[0];
|
const rootCourseId = courses && Object.keys(courses)[0];
|
||||||
|
|
||||||
@@ -90,6 +87,14 @@ function OutlineTab({ intl }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<AlertList
|
||||||
|
topic="outline"
|
||||||
|
className="mb-3"
|
||||||
|
customAlerts={{
|
||||||
|
...enrollmentAlert,
|
||||||
|
...logistrationAlert,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Toast
|
<Toast
|
||||||
closeLabel={intl.formatMessage(genericMessages.close)}
|
closeLabel={intl.formatMessage(genericMessages.close)}
|
||||||
onClose={() => setGoalToastHeader('')}
|
onClose={() => setGoalToastHeader('')}
|
||||||
@@ -99,26 +104,27 @@ function OutlineTab({ intl }) {
|
|||||||
</Toast>
|
</Toast>
|
||||||
<div className="row w-100 m-0 mb-3 justify-content-between">
|
<div className="row w-100 m-0 mb-3 justify-content-between">
|
||||||
<div className="col-12 col-sm-auto p-0">
|
<div className="col-12 col-sm-auto p-0">
|
||||||
<div role="heading" aria-level="1" className="h2">{title}</div>
|
<div role="heading" aria-level="1" className="h4">{title}</div>
|
||||||
</div>
|
</div>
|
||||||
{resumeCourseUrl && (
|
{resumeCourseUrl && (
|
||||||
<div className="col-12 col-sm-auto p-0">
|
<div className="col-12 col-sm-auto p-0">
|
||||||
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
|
<a className="btn btn-primary btn-block" href={resumeCourseUrl}>
|
||||||
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
|
||||||
</Button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
|
||||||
<AlertList
|
|
||||||
topic="outline-private-alerts"
|
|
||||||
customAlerts={{
|
|
||||||
...privateCourseAlert,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col col-12 col-md-8">
|
<div className="col col-12 col-md-8">
|
||||||
|
{!courseGoalToDisplay && goalOptions.length > 0 && (
|
||||||
|
<CourseGoalCard
|
||||||
|
courseId={courseId}
|
||||||
|
goalOptions={goalOptions}
|
||||||
|
title={title}
|
||||||
|
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
||||||
|
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<AlertList
|
<AlertList
|
||||||
topic="outline-course-alerts"
|
topic="outline-course-alerts"
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
@@ -130,7 +136,6 @@ function OutlineTab({ intl }) {
|
|||||||
...offerAlert,
|
...offerAlert,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{courseDateBlocks && (
|
|
||||||
<DatesBannerContainer
|
<DatesBannerContainer
|
||||||
courseDateBlocks={courseDateBlocks}
|
courseDateBlocks={courseDateBlocks}
|
||||||
datesBannerInfo={datesBannerInfo}
|
datesBannerInfo={datesBannerInfo}
|
||||||
@@ -138,16 +143,6 @@ function OutlineTab({ intl }) {
|
|||||||
model="outline"
|
model="outline"
|
||||||
tabFetch={fetchOutlineTab}
|
tabFetch={fetchOutlineTab}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{!courseGoalToDisplay && goalOptions.length > 0 && (
|
|
||||||
<CourseGoalCard
|
|
||||||
courseId={courseId}
|
|
||||||
goalOptions={goalOptions}
|
|
||||||
title={title}
|
|
||||||
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
|
|
||||||
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<WelcomeMessage courseId={courseId} />
|
<WelcomeMessage courseId={courseId} />
|
||||||
{rootCourseId && (
|
{rootCourseId && (
|
||||||
<>
|
<>
|
||||||
@@ -158,7 +153,6 @@ function OutlineTab({ intl }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ol className="list-unstyled">
|
|
||||||
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
{courses[rootCourseId].sectionIds.map((sectionId) => (
|
||||||
<Section
|
<Section
|
||||||
key={sectionId}
|
key={sectionId}
|
||||||
@@ -168,15 +162,10 @@ function OutlineTab({ intl }) {
|
|||||||
section={sections[sectionId]}
|
section={sections[sectionId]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rootCourseId && (
|
|
||||||
<div className="col col-12 col-md-4">
|
<div className="col col-12 col-md-4">
|
||||||
<ProctoringInfoPanel
|
|
||||||
courseId={courseId}
|
|
||||||
/>
|
|
||||||
{courseGoalToDisplay && goalOptions.length > 0 && (
|
{courseGoalToDisplay && goalOptions.length > 0 && (
|
||||||
<UpdateGoalSelector
|
<UpdateGoalSelector
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
@@ -194,24 +183,20 @@ function OutlineTab({ intl }) {
|
|||||||
onLearnMore={canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null}
|
onLearnMore={canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null}
|
||||||
/>
|
/>
|
||||||
<CourseDates
|
<CourseDates
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
enrollmentStart={enrollmentStart}
|
||||||
|
enrollmentEnd={enrollmentEnd}
|
||||||
|
enrollmentMode={enrollmentMode}
|
||||||
|
isEnrolled={isEnrolled}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
/>
|
/>
|
||||||
<CourseHandouts
|
<CourseHandouts
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{canShowUpgradeSock && (
|
{canShowUpgradeSock && <CourseSock ref={courseSock} verifiedMode={verifiedMode} />}
|
||||||
<CourseSock
|
|
||||||
courseId={courseId}
|
|
||||||
offer={offer}
|
|
||||||
orgKey={org}
|
|
||||||
pageLocation="Home Page"
|
|
||||||
ref={courseSock}
|
|
||||||
verifiedMode={verifiedMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { ALERT_TYPES } from '../../generic/user-messages';
|
||||||
import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory';
|
import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory';
|
||||||
import {
|
import {
|
||||||
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, act,
|
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor,
|
||||||
} from '../../setupTest';
|
} from '../../setupTest';
|
||||||
import executeThunk from '../../utils';
|
import executeThunk from '../../utils';
|
||||||
import * as thunks from '../data/thunks';
|
import * as thunks from '../data/thunks';
|
||||||
@@ -25,7 +26,6 @@ describe('Outline Tab', () => {
|
|||||||
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||||
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
const goalUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`;
|
||||||
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
const outlineUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
|
||||||
const proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
|
|
||||||
|
|
||||||
const store = initializeStore();
|
const store = initializeStore();
|
||||||
const defaultMetadata = Factory.build('courseHomeMetadata', { courseId });
|
const defaultMetadata = Factory.build('courseHomeMetadata', { courseId });
|
||||||
@@ -43,7 +43,7 @@ describe('Outline Tab', () => {
|
|||||||
|
|
||||||
async function fetchAndRender() {
|
async function fetchAndRender() {
|
||||||
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
|
||||||
await act(async () => render(<OutlineTab />, { store }));
|
render(<OutlineTab />, { store });
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -54,7 +54,6 @@ describe('Outline Tab', () => {
|
|||||||
axiosMock.onPost(enrollmentUrl).reply(200, {});
|
axiosMock.onPost(enrollmentUrl).reply(200, {});
|
||||||
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
|
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
|
||||||
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
|
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'created', onboarding_link: 'test' });
|
|
||||||
|
|
||||||
logUnhandledRequests(axiosMock);
|
logUnhandledRequests(axiosMock);
|
||||||
});
|
});
|
||||||
@@ -73,7 +72,7 @@ describe('Outline Tab', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
expect(screen.getByRole('link', { name: 'Resume course' })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: 'Resume Course' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands section that contains resume block', async () => {
|
it('expands section that contains resume block', async () => {
|
||||||
@@ -89,7 +88,7 @@ describe('Outline Tab', () => {
|
|||||||
it('handles expand/collapse all button click', async () => {
|
it('handles expand/collapse all button click', async () => {
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
// Button renders as "Expand All"
|
// Button renders as "Expand All"
|
||||||
const expandButton = screen.getByRole('button', { name: 'Expand all' });
|
const expandButton = screen.getByRole('button', { name: 'Expand All' });
|
||||||
expect(expandButton).toBeInTheDocument();
|
expect(expandButton).toBeInTheDocument();
|
||||||
|
|
||||||
// Section initially renders collapsed
|
// Section initially renders collapsed
|
||||||
@@ -125,10 +124,6 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Welcome Message', () => {
|
describe('Welcome Message', () => {
|
||||||
beforeEach(() => {
|
|
||||||
setMetadata({ is_enrolled: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render show more/less button under 100 words', async () => {
|
it('does not render show more/less button under 100 words', async () => {
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
|
||||||
@@ -279,12 +274,20 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Alert List', () => {
|
describe('Alert List', () => {
|
||||||
describe('Private Course Alert', () => {
|
describe('Enrollment Alert', () => {
|
||||||
it('does not display alert for enrolled user', async () => {
|
let alertMessage;
|
||||||
|
let staffMessage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const extraText = defaultTabData.enroll_alert.extra_text;
|
||||||
|
alertMessage = `You must be enrolled in the course to see course content. ${extraText}`;
|
||||||
|
staffMessage = 'You are viewing this course as staff, and are not enrolled.';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not display enrollment alert for enrolled user', async () => {
|
||||||
setMetadata({ is_enrolled: true });
|
setMetadata({ is_enrolled: true });
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
|
expect(screen.queryByText(alertMessage)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('to access the full course')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not display enrollment button if enrollment is not available', async () => {
|
it('does not display enrollment button if enrollment is not available', async () => {
|
||||||
@@ -294,21 +297,29 @@ describe('Outline Tab', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Enroll Now' })).not.toBeInTheDocument();
|
||||||
const alert = await screen.findByText('Welcome to Demonstration Course');
|
|
||||||
expect(alert.parentElement).toHaveAttribute('role', 'alert');
|
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('You must be enrolled in the course to see course content.')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays alert for unenrolled user', async () => {
|
it('displays enrollment alert for unenrolled user', async () => {
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
|
|
||||||
const alert = await screen.findByText('Welcome to Demonstration Course');
|
const alert = await screen.findByText(alertMessage);
|
||||||
expect(alert.parentElement).toHaveAttribute('role', 'alert');
|
expect(alert).toHaveAttribute('role', 'alert');
|
||||||
|
const alertContainer = await screen.findByTestId(`alert-container-${ALERT_TYPES.ERROR}`);
|
||||||
|
expect(screen.queryByText(staffMessage)).not.toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
|
expect(alertContainer.querySelector('svg')).toHaveClass('fa-exclamation-triangle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays different message for unenrolled staff user', async () => {
|
||||||
|
setMetadata({ is_staff: true });
|
||||||
|
await fetchAndRender();
|
||||||
|
|
||||||
|
const alert = await screen.findByText(staffMessage);
|
||||||
|
expect(alert).toHaveAttribute('role', 'alert');
|
||||||
|
expect(screen.queryByText(alertMessage)).not.toBeInTheDocument();
|
||||||
|
const alertContainer = await screen.findByTestId(`alert-container-${ALERT_TYPES.INFO}`);
|
||||||
|
expect(alertContainer.querySelector('svg')).toHaveClass('fa-info-circle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles button click', async () => {
|
it('handles button click', async () => {
|
||||||
@@ -319,7 +330,7 @@ describe('Outline Tab', () => {
|
|||||||
};
|
};
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
|
|
||||||
const button = await screen.findByRole('button', { name: 'Enroll now' });
|
const button = await screen.findByRole('button', { name: 'Enroll Now' });
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
|
||||||
expect(axiosMock.history.post[0].data)
|
expect(axiosMock.history.post[0].data)
|
||||||
@@ -331,43 +342,11 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Access Expiration Alert', () => {
|
describe('Access Expiration Alert', () => {
|
||||||
it('has special masquerade text', async () => {
|
// Appears if course_expired_html is provided
|
||||||
setTabData({
|
it('appears', async () => {
|
||||||
access_expiration: {
|
setTabData({ course_expired_html: '<p>Course Will Expire, Uh Oh</p>' });
|
||||||
expiration_date: '2020-01-01T12:00:00Z',
|
|
||||||
masquerading_expired_course: true,
|
|
||||||
upgrade_deadline: null,
|
|
||||||
upgrade_url: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
await fetchAndRender();
|
||||||
await screen.findByText('This learner does not have access to this course.', { exact: false });
|
await screen.findByText('Course Will Expire, Uh Oh');
|
||||||
});
|
|
||||||
|
|
||||||
it('shows expiration', async () => {
|
|
||||||
setTabData({
|
|
||||||
access_expiration: {
|
|
||||||
expiration_date: '2080-01-01T12:00:00Z',
|
|
||||||
masquerading_expired_course: false,
|
|
||||||
upgrade_deadline: null,
|
|
||||||
upgrade_url: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('Audit Access Expires');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows upgrade prompt', async () => {
|
|
||||||
setTabData({
|
|
||||||
access_expiration: {
|
|
||||||
expiration_date: '2080-01-01T12:00:00Z',
|
|
||||||
masquerading_expired_course: false,
|
|
||||||
upgrade_deadline: '2070-01-01T12:00:00Z',
|
|
||||||
upgrade_url: 'https://example.com/upgrade',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('to get unlimited access to the course as long as it exists on the site.', { exact: false });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -475,63 +454,4 @@ describe('Outline Tab', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Proctoring Info Panel', () => {
|
|
||||||
it('appears', async () => {
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appears for verified', async () => {
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'verified', onboarding_link: 'test' });
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appears for rejected', async () => {
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'rejected', onboarding_link: 'test' });
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appears for submitted', async () => {
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'submitted', onboarding_link: 'test' });
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appears for second_review_required', async () => {
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'second_review_required', onboarding_link: 'test' });
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByText('Your submitted profile is in review.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appears for no status', async () => {
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: '', onboarding_link: 'test' });
|
|
||||||
await fetchAndRender();
|
|
||||||
await screen.findByText('This course contains proctored exams');
|
|
||||||
expect(screen.queryByRole('link', { name: 'Complete Onboarding' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You must complete the onboarding process prior to taking any proctored exam.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Onboarding profile review, including identity verification, can take 2+ business days.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not appear for 404', async () => {
|
|
||||||
axiosMock.onGet(proctoringInfoUrl).reply(404);
|
|
||||||
expect(screen.queryByRole('link', { name: 'Review instructions and system requirements' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,12 +39,10 @@ function Section({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sectionTitle = (
|
const sectionTitle = (
|
||||||
<div className="row w-100 m-0">
|
<div>
|
||||||
<div className="col-auto p-0">
|
|
||||||
{complete ? (
|
{complete ? (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={fasCheckCircle}
|
icon={fasCheckCircle}
|
||||||
fixedWidth
|
|
||||||
className="float-left mt-1 text-success"
|
className="float-left mt-1 text-success"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
title={intl.formatMessage(messages.completedSection)}
|
title={intl.formatMessage(messages.completedSection)}
|
||||||
@@ -52,14 +50,12 @@ function Section({
|
|||||||
) : (
|
) : (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={farCheckCircle}
|
icon={farCheckCircle}
|
||||||
fixedWidth
|
className="float-left mt-1 text-gray-200"
|
||||||
className="float-left mt-1 text-gray-400"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
title={intl.formatMessage(messages.incompleteSection)}
|
title={intl.formatMessage(messages.incompleteSection)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="ml-3 pl-3 font-weight-bold">
|
||||||
<div className="col-10 ml-3 p-0 font-weight-bold text-dark-500">
|
|
||||||
{title}
|
{title}
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
|
||||||
@@ -69,7 +65,6 @@ function Section({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
|
||||||
<Collapsible
|
<Collapsible
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
styling="card-lg"
|
styling="card-lg"
|
||||||
@@ -91,7 +86,6 @@ function Section({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ol className="list-unstyled">
|
|
||||||
{sequenceIds.map((sequenceId, index) => (
|
{sequenceIds.map((sequenceId, index) => (
|
||||||
<SequenceLink
|
<SequenceLink
|
||||||
key={sequenceId}
|
key={sequenceId}
|
||||||
@@ -101,9 +95,7 @@ function Section({
|
|||||||
first={index === 0}
|
first={index === 0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ function SequenceLink({
|
|||||||
const displayTitle = showLink ? <Link to={`/course/${courseId}/${id}`}>{title}</Link> : title;
|
const displayTitle = showLink ? <Link to={`/course/${courseId}/${id}`}>{title}</Link> : title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
|
||||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||||
<div className="row w-100 m-0">
|
<div className="row w-100 m-0">
|
||||||
<div className="col-auto p-0">
|
<div className="col-auto p-0">
|
||||||
@@ -56,20 +55,20 @@ function SequenceLink({
|
|||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={farCheckCircle}
|
icon={farCheckCircle}
|
||||||
fixedWidth
|
fixedWidth
|
||||||
className="float-left text-gray-400 mt-1"
|
className="float-left text-gray-200 mt-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-10 p-0 ml-3 text-break">{displayTitle}</div>
|
<div className="col-10 p-0 ml-2 pl-1 text-break">{displayTitle}</div>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{due && (
|
{due && (
|
||||||
<div className="row w-100 m-0 ml-3 pl-3">
|
<div className="row w-100 m-0 ml-3 pl-3">
|
||||||
<small className="text-body pl-2">
|
<small className="text-body">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="learning.outline.sequence-due"
|
id="learning.outline.sequence-due"
|
||||||
defaultMessage="{description} due {assignmentDue}"
|
defaultMessage="{description} due {assignmentDue}"
|
||||||
@@ -94,7 +93,6 @@ function SequenceLink({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,14 @@ const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailabl
|
|||||||
function useCertificateAvailableAlert(courseId) {
|
function useCertificateAvailableAlert(courseId) {
|
||||||
const {
|
const {
|
||||||
isEnrolled,
|
isEnrolled,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
datesWidget: {
|
datesWidget: {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
},
|
},
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
const authenticatedUser = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
const username = authenticatedUser ? authenticatedUser.username : '';
|
|
||||||
|
|
||||||
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
|
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
|
||||||
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
|||||||
|
|
||||||
function CourseEndAlert({ payload }) {
|
function CourseEndAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
|
delta,
|
||||||
description,
|
description,
|
||||||
endDate,
|
endDate,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
@@ -29,7 +30,6 @@ function CourseEndAlert({ payload }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let msg;
|
let msg;
|
||||||
const delta = new Date(endDate) - new Date();
|
|
||||||
if (delta < DAY_MS) {
|
if (delta < DAY_MS) {
|
||||||
const courseEndTime = (
|
const courseEndTime = (
|
||||||
<FormattedTime
|
<FormattedTime
|
||||||
@@ -88,6 +88,7 @@ function CourseEndAlert({ payload }) {
|
|||||||
|
|
||||||
CourseEndAlert.propTypes = {
|
CourseEndAlert.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
delta: PropTypes.number,
|
||||||
description: PropTypes.string,
|
description: PropTypes.string,
|
||||||
endDate: PropTypes.string,
|
endDate: PropTypes.string,
|
||||||
userTimezone: PropTypes.string,
|
userTimezone: PropTypes.string,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
|
|||||||
export function useCourseEndAlert(courseId) {
|
export function useCourseEndAlert(courseId) {
|
||||||
const {
|
const {
|
||||||
isEnrolled,
|
isEnrolled,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
datesWidget: {
|
datesWidget: {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
@@ -24,6 +24,7 @@ export function useCourseEndAlert(courseId) {
|
|||||||
const delta = endBlock ? endDate - new Date() : 0;
|
const delta = endBlock ? endDate - new Date() : 0;
|
||||||
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
|
||||||
const payload = {
|
const payload = {
|
||||||
|
delta,
|
||||||
description: endBlock && endBlock.description,
|
description: endBlock && endBlock.description,
|
||||||
endDate: endBlock && endBlock.date,
|
endDate: endBlock && endBlock.date,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; // in ms
|
|||||||
|
|
||||||
function CourseStartAlert({ payload }) {
|
function CourseStartAlert({ payload }) {
|
||||||
const {
|
const {
|
||||||
|
delta,
|
||||||
startDate,
|
startDate,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
} = payload;
|
} = payload;
|
||||||
@@ -27,7 +28,6 @@ function CourseStartAlert({ payload }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const delta = new Date(startDate) - new Date();
|
|
||||||
if (delta < DAY_MS) {
|
if (delta < DAY_MS) {
|
||||||
return (
|
return (
|
||||||
<Alert type={ALERT_TYPES.INFO}>
|
<Alert type={ALERT_TYPES.INFO}>
|
||||||
@@ -88,6 +88,7 @@ function CourseStartAlert({ payload }) {
|
|||||||
|
|
||||||
CourseStartAlert.propTypes = {
|
CourseStartAlert.propTypes = {
|
||||||
payload: PropTypes.shape({
|
payload: PropTypes.shape({
|
||||||
|
delta: PropTypes.number,
|
||||||
startDate: PropTypes.string,
|
startDate: PropTypes.string,
|
||||||
userTimezone: PropTypes.string,
|
userTimezone: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React from 'react';
|
||||||
import { useAlert } from '../../../../generic/user-messages';
|
import { useAlert } from '../../../../generic/user-messages';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
|||||||
function useCourseStartAlert(courseId) {
|
function useCourseStartAlert(courseId) {
|
||||||
const {
|
const {
|
||||||
isEnrolled,
|
isEnrolled,
|
||||||
} = useModel('courseHomeMeta', courseId);
|
} = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
datesWidget: {
|
datesWidget: {
|
||||||
courseDateBlocks,
|
courseDateBlocks,
|
||||||
@@ -18,14 +18,14 @@ function useCourseStartAlert(courseId) {
|
|||||||
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
|
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
|
||||||
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
|
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
|
||||||
const isVisible = isEnrolled && startBlock && delta > 0;
|
const isVisible = isEnrolled && startBlock && delta > 0;
|
||||||
const payload = {
|
|
||||||
startDate: startBlock && startBlock.date,
|
|
||||||
userTimezone,
|
|
||||||
};
|
|
||||||
|
|
||||||
useAlert(isVisible, {
|
useAlert(isVisible, {
|
||||||
code: 'clientCourseStartAlert',
|
code: 'clientCourseStartAlert',
|
||||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
payload: {
|
||||||
|
delta,
|
||||||
|
startDate: startBlock && startBlock.date,
|
||||||
|
userTimezone,
|
||||||
|
},
|
||||||
topic: 'outline-course-alerts',
|
topic: 'outline-course-alerts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
|
||||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
|
||||||
import { Button, Hyperlink } from '@edx/paragon';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import { Alert } from '../../../../generic/user-messages';
|
|
||||||
import enrollmentMessages from '../../../../alerts/enrollment-alert/messages';
|
|
||||||
import genericMessages from '../../../../generic/messages';
|
|
||||||
import outlineMessages from '../../messages';
|
|
||||||
import { useEnrollClickHandler } from '../../../../alerts/enrollment-alert/hooks';
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
|
||||||
|
|
||||||
function PrivateCourseAlert({ intl, payload }) {
|
|
||||||
const {
|
|
||||||
anonymousUser,
|
|
||||||
canEnroll,
|
|
||||||
courseId,
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
const {
|
|
||||||
org,
|
|
||||||
title,
|
|
||||||
} = useModel('courseHomeMeta', courseId);
|
|
||||||
|
|
||||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
|
||||||
courseId,
|
|
||||||
org,
|
|
||||||
intl.formatMessage(enrollmentMessages.success),
|
|
||||||
);
|
|
||||||
|
|
||||||
const enrollNow = (
|
|
||||||
<Button
|
|
||||||
disabled={loading}
|
|
||||||
variant="link"
|
|
||||||
className="p-0 border-0 align-top"
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
onClick={enrollClickHandler}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(enrollmentMessages.enrollNowInline)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const register = (
|
|
||||||
<Hyperlink
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.registerLowercase)}
|
|
||||||
</Hyperlink>
|
|
||||||
);
|
|
||||||
|
|
||||||
const signIn = (
|
|
||||||
<Hyperlink
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
destination={`${getLoginRedirectUrl(global.location.href)}`}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(genericMessages.signInSentenceCase)}
|
|
||||||
</Hyperlink>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert type="welcome">
|
|
||||||
{anonymousUser && (
|
|
||||||
<>
|
|
||||||
<p className="font-weight-bold">
|
|
||||||
{intl.formatMessage(enrollmentMessages.alert)}
|
|
||||||
</p>
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.privateCourse.signInOrRegister"
|
|
||||||
description="Prompts the user to sign in or register to see course content."
|
|
||||||
defaultMessage="{signIn} or {register} and then enroll in this course."
|
|
||||||
values={{
|
|
||||||
signIn,
|
|
||||||
register,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!anonymousUser && (
|
|
||||||
<>
|
|
||||||
<p className="font-weight-bold">{intl.formatMessage(outlineMessages.welcomeTo)} {title}</p>
|
|
||||||
{canEnroll && (
|
|
||||||
<>
|
|
||||||
<FormattedMessage
|
|
||||||
id="learning.privateCourse.canEnroll"
|
|
||||||
description="Prompts the user to enroll in the course to see course content."
|
|
||||||
defaultMessage="{enrollNow} to access the full course."
|
|
||||||
values={{ enrollNow }}
|
|
||||||
/>
|
|
||||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!canEnroll && (
|
|
||||||
<>
|
|
||||||
{intl.formatMessage(enrollmentMessages.alert)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PrivateCourseAlert.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
payload: PropTypes.shape({
|
|
||||||
anonymousUser: PropTypes.bool,
|
|
||||||
canEnroll: PropTypes.bool,
|
|
||||||
courseId: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(PrivateCourseAlert);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
import React, { useContext, useMemo } from 'react';
|
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
|
||||||
import { ALERT_TYPES, useAlert } from '../../../../generic/user-messages';
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
|
||||||
|
|
||||||
const PrivateCourseAlert = React.lazy(() => import('./PrivateCourseAlert'));
|
|
||||||
|
|
||||||
export function usePrivateCourseAlert(courseId) {
|
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
|
||||||
const course = useModel('courseHomeMeta', courseId);
|
|
||||||
const outline = useModel('outline', courseId);
|
|
||||||
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
|
|
||||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
|
||||||
/**
|
|
||||||
* This alert should render if the user is not enrolled AND
|
|
||||||
* 1. the user is anonymous AND the outline is private, OR
|
|
||||||
* 2. the user is authenticated.
|
|
||||||
* */
|
|
||||||
const isVisible = !enrolledUser && (privateOutline || authenticatedUser !== null);
|
|
||||||
const payload = {
|
|
||||||
anonymousUser: authenticatedUser === null,
|
|
||||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
|
||||||
courseId,
|
|
||||||
};
|
|
||||||
|
|
||||||
useAlert(isVisible, {
|
|
||||||
code: 'clientPrivateCourseAlert',
|
|
||||||
dismissible: false,
|
|
||||||
payload: useMemo(() => payload, Object.values(payload).sort()),
|
|
||||||
topic: 'outline-private-alerts',
|
|
||||||
type: ALERT_TYPES.WELCOME,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { clientPrivateCourseAlert: PrivateCourseAlert };
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { usePrivateCourseAlert as default } from './hooks';
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
enroll: {
|
|
||||||
id: 'alert.enroll',
|
|
||||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
|
||||||
description: 'Text instructing the learner to enroll in the course in order to see course content.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default messages;
|
|
||||||
@@ -7,7 +7,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
collapseAll: {
|
collapseAll: {
|
||||||
id: 'learning.outline.collapseAll',
|
id: 'learning.outline.collapseAll',
|
||||||
defaultMessage: 'Collapse all',
|
defaultMessage: 'Collapse All',
|
||||||
description: 'Label for button to close all of the collapsible sections',
|
description: 'Label for button to close all of the collapsible sections',
|
||||||
},
|
},
|
||||||
completedAssignment: {
|
completedAssignment: {
|
||||||
@@ -31,7 +31,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
expandAll: {
|
expandAll: {
|
||||||
id: 'learning.outline.expandAll',
|
id: 'learning.outline.expandAll',
|
||||||
defaultMessage: 'Expand all',
|
defaultMessage: 'Expand All',
|
||||||
description: 'Label for button to open all of the collapsible sections',
|
description: 'Label for button to open all of the collapsible sections',
|
||||||
},
|
},
|
||||||
goal: {
|
goal: {
|
||||||
@@ -43,6 +43,11 @@ const messages = defineMessages({
|
|||||||
id: 'learning.outline.goalUnsure',
|
id: 'learning.outline.goalUnsure',
|
||||||
defaultMessage: 'Not sure yet',
|
defaultMessage: 'Not sure yet',
|
||||||
},
|
},
|
||||||
|
goalWelcome: {
|
||||||
|
id: 'learning.outline.goalWelcome',
|
||||||
|
defaultMessage: 'Welcome to',
|
||||||
|
description: 'This precedes the title of the course',
|
||||||
|
},
|
||||||
handouts: {
|
handouts: {
|
||||||
id: 'learning.outline.handouts',
|
id: 'learning.outline.handouts',
|
||||||
defaultMessage: 'Course Handouts',
|
defaultMessage: 'Course Handouts',
|
||||||
@@ -68,7 +73,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
resume: {
|
resume: {
|
||||||
id: 'learning.outline.resume',
|
id: 'learning.outline.resume',
|
||||||
defaultMessage: 'Resume course',
|
defaultMessage: 'Resume Course',
|
||||||
},
|
},
|
||||||
setGoal: {
|
setGoal: {
|
||||||
id: 'learning.outline.setGoal',
|
id: 'learning.outline.setGoal',
|
||||||
@@ -107,87 +112,6 @@ const messages = defineMessages({
|
|||||||
id: 'learning.outline.welcomeMessageShowLessButton',
|
id: 'learning.outline.welcomeMessageShowLessButton',
|
||||||
defaultMessage: 'Show Less',
|
defaultMessage: 'Show Less',
|
||||||
},
|
},
|
||||||
welcomeTo: {
|
|
||||||
id: 'learning.outline.goalWelcome',
|
|
||||||
defaultMessage: 'Welcome to',
|
|
||||||
description: 'This precedes the title of the course',
|
|
||||||
},
|
|
||||||
proctoringInfoPanel: {
|
|
||||||
id: 'learning.proctoringPanel.header',
|
|
||||||
defaultMessage: 'This course contains proctored exams',
|
|
||||||
},
|
|
||||||
notStartedProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.notStarted',
|
|
||||||
defaultMessage: 'Not Started',
|
|
||||||
},
|
|
||||||
startedProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.started',
|
|
||||||
defaultMessage: 'Started',
|
|
||||||
},
|
|
||||||
submittedProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.submitted',
|
|
||||||
defaultMessage: 'Submitted',
|
|
||||||
},
|
|
||||||
verifiedProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.verified',
|
|
||||||
defaultMessage: 'Verified',
|
|
||||||
},
|
|
||||||
rejectedProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.rejected',
|
|
||||||
defaultMessage: 'Rejected',
|
|
||||||
},
|
|
||||||
errorProctoringStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status.error',
|
|
||||||
defaultMessage: 'Error',
|
|
||||||
},
|
|
||||||
proctoringCurrentStatus: {
|
|
||||||
id: 'learning.proctoringPanel.status',
|
|
||||||
defaultMessage: 'Current Onboarding Status:',
|
|
||||||
},
|
|
||||||
notStartedProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.notStarted',
|
|
||||||
defaultMessage: 'You have not started your onboarding exam.',
|
|
||||||
},
|
|
||||||
startedProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.started',
|
|
||||||
defaultMessage: 'You have started your onboarding exam.',
|
|
||||||
},
|
|
||||||
submittedProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.submitted',
|
|
||||||
defaultMessage: 'You have submitted your onboarding exam.',
|
|
||||||
},
|
|
||||||
verifiedProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.verified',
|
|
||||||
defaultMessage: 'You can now take proctored exams in this course.',
|
|
||||||
},
|
|
||||||
rejectedProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.rejected',
|
|
||||||
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
|
|
||||||
},
|
|
||||||
errorProctoringMessage: {
|
|
||||||
id: 'learning.proctoringPanel.message.error',
|
|
||||||
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
|
|
||||||
},
|
|
||||||
proctoringPanelGeneralInfo: {
|
|
||||||
id: 'learning.proctoringPanel.generalInfo',
|
|
||||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
|
||||||
},
|
|
||||||
proctoringPanelGeneralInfoSubmitted: {
|
|
||||||
id: 'learning.proctoringPanel.generalInfoSubmitted',
|
|
||||||
defaultMessage: 'Your submitted profile is in review.',
|
|
||||||
},
|
|
||||||
proctoringPanelGeneralTime: {
|
|
||||||
id: 'learning.proctoringPanel.generalTime',
|
|
||||||
defaultMessage: 'Onboarding profile review, including identity verification, can take 2+ business days.',
|
|
||||||
},
|
|
||||||
proctoringOnboardingButton: {
|
|
||||||
id: 'learning.proctoringPanel.onboardingButton',
|
|
||||||
defaultMessage: 'Complete Onboarding',
|
|
||||||
},
|
|
||||||
proctoringReviewRequirementsButton: {
|
|
||||||
id: 'learning.proctoringPanel.reviewRequirementsButton',
|
|
||||||
defaultMessage: 'Review instructions and system requirements',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -16,14 +16,9 @@ function CourseDates({ courseId, intl }) {
|
|||||||
},
|
},
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
|
|
||||||
if (courseDateBlocks.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-4">
|
<section className="mb-4">
|
||||||
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
|
<h2 className="h6">{intl.formatMessage(messages.dates)}</h2>
|
||||||
<ol className="list-unstyled">
|
|
||||||
{courseDateBlocks.map((courseDateBlock) => (
|
{courseDateBlocks.map((courseDateBlock) => (
|
||||||
<DateSummary
|
<DateSummary
|
||||||
key={courseDateBlock.title + courseDateBlock.date}
|
key={courseDateBlock.title + courseDateBlock.date}
|
||||||
@@ -31,8 +26,7 @@ function CourseDates({ courseId, intl }) {
|
|||||||
userTimezone={userTimezone}
|
userTimezone={userTimezone}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
<a className="font-weight-bold ml-4 pl-1" href={datesTabLink}>
|
||||||
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
|
|
||||||
{intl.formatMessage(messages.allDates)}
|
{intl.formatMessage(messages.allDates)}
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function CourseGoalCard({
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="row w-100 m-0 justify-content-between align-items-center">
|
<div className="row w-100 m-0 justify-content-between align-items-center">
|
||||||
<div className="col col-8 p-0">
|
<div className="col col-8 p-0">
|
||||||
<h2 className="h4 m-0">{intl.formatMessage(messages.welcomeTo)} {title}</h2>
|
<Card.Title className="h6 m-0">{intl.formatMessage(messages.goalWelcome)} {title}</Card.Title>
|
||||||
</div>
|
</div>
|
||||||
<div className="col col-auto p-0">
|
<div className="col col-auto p-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -54,7 +54,7 @@ function CourseGoalCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Card.Text className="my-2 mx-1 text-dark-500">{intl.formatMessage(messages.setGoal)}</Card.Text>
|
<Card.Text className="my-2 mx-1">{intl.formatMessage(messages.setGoal)}</Card.Text>
|
||||||
<div className="row w-100 m-0">
|
<div className="row w-100 m-0">
|
||||||
{goalOptions.map((goal) => {
|
{goalOptions.map((goal) => {
|
||||||
const [goalKey, goalText] = goal;
|
const [goalKey, goalText] = goal;
|
||||||
|
|||||||
@@ -18,9 +18,8 @@ function CourseHandouts({ courseId, intl }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-4">
|
<section className="mb-4">
|
||||||
<h2 className="h4">{intl.formatMessage(messages.handouts)}</h2>
|
<h2 className="h6">{intl.formatMessage(messages.handouts)}</h2>
|
||||||
<LmsHtmlFragment
|
<LmsHtmlFragment
|
||||||
className="small"
|
|
||||||
html={handoutsHtml}
|
html={handoutsHtml}
|
||||||
title={intl.formatMessage(messages.handouts)}
|
title={intl.formatMessage(messages.handouts)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import messages from '../messages';
|
|||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
function CourseTools({ courseId, intl }) {
|
function CourseTools({ courseId, intl }) {
|
||||||
const { org } = useModel('courseHomeMeta', courseId);
|
const { org } = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
courseTools,
|
courseTools,
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
@@ -55,17 +55,15 @@ function CourseTools({ courseId, intl }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-4">
|
<section className="mb-4">
|
||||||
<h2 className="h4">{intl.formatMessage(messages.tools)}</h2>
|
<h2 className="h6">{intl.formatMessage(messages.tools)}</h2>
|
||||||
<ul className="list-unstyled">
|
|
||||||
{courseTools.map((courseTool) => (
|
{courseTools.map((courseTool) => (
|
||||||
<li key={courseTool.analyticsId} className="small">
|
<div key={courseTool.analyticsId}>
|
||||||
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
|
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
|
||||||
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" fixedWidth />
|
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" fixedWidth />
|
||||||
{courseTool.title}
|
{courseTool.title}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Button } from '@edx/paragon';
|
|
||||||
|
|
||||||
import messages from '../messages';
|
|
||||||
import { getProctoringInfoData } from '../../data/api';
|
|
||||||
|
|
||||||
function ProctoringInfoPanel({ courseId, intl }) {
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [link, setLink] = useState('');
|
|
||||||
const [readableStatus, setReadableStatus] = useState('');
|
|
||||||
|
|
||||||
function getReadableStatusClass(examStatus) {
|
|
||||||
let readableClass = '';
|
|
||||||
if (['created', 'download_software_clicked', 'ready_to_start'].includes(examStatus) || !examStatus) {
|
|
||||||
readableClass = 'notStarted';
|
|
||||||
} else if (['started', 'ready_to_submit'].includes(examStatus)) {
|
|
||||||
readableClass = 'started';
|
|
||||||
} else if (['second_review_required', 'submitted'].includes(examStatus)) {
|
|
||||||
readableClass = 'submitted';
|
|
||||||
} else if (['verified', 'rejected', 'error'].includes(examStatus)) {
|
|
||||||
readableClass = examStatus;
|
|
||||||
}
|
|
||||||
return readableClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNotYetSubmitted(examStatus) {
|
|
||||||
const NO_SHOW_STATES = ['submitted', 'second_review_required', 'verified'];
|
|
||||||
return !NO_SHOW_STATES.includes(examStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBorderClass(examStatus) {
|
|
||||||
let borderClass = '';
|
|
||||||
if (['submitted', 'second_review_required'].includes(examStatus)) {
|
|
||||||
borderClass = 'proctoring-onboarding-submitted';
|
|
||||||
} else if (examStatus === 'verified') {
|
|
||||||
borderClass = 'proctoring-onboarding-success';
|
|
||||||
}
|
|
||||||
return borderClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getProctoringInfoData(courseId)
|
|
||||||
.then(
|
|
||||||
response => {
|
|
||||||
if (response) {
|
|
||||||
setStatus(response.onboarding_status);
|
|
||||||
setLink(response.onboarding_link);
|
|
||||||
setReadableStatus(getReadableStatusClass(response.onboarding_status));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{ link && (
|
|
||||||
<section className={`mb-4 p-3 outline-sidebar-proctoring-panel ${getBorderClass(status)}`}>
|
|
||||||
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.proctoringInfoPanel)}</h2>
|
|
||||||
<div>
|
|
||||||
{readableStatus && (
|
|
||||||
<>
|
|
||||||
<p className="h6">
|
|
||||||
{intl.formatMessage(messages.proctoringCurrentStatus)} {intl.formatMessage(messages[`${readableStatus}ProctoringStatus`])}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage(messages[`${readableStatus}ProctoringMessage`])}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(readableStatus !== 'verified') && (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
{isNotYetSubmitted(status) && (
|
|
||||||
<>
|
|
||||||
{intl.formatMessage(messages.proctoringPanelGeneralInfo)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isNotYetSubmitted(status) && (
|
|
||||||
<>
|
|
||||||
{intl.formatMessage(messages.proctoringPanelGeneralInfoSubmitted)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p>{intl.formatMessage(messages.proctoringPanelGeneralTime)}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isNotYetSubmitted(status) && (
|
|
||||||
<Button variant="primary" block href={`${getConfig().LMS_BASE_URL}${link}`}>
|
|
||||||
{intl.formatMessage(messages.proctoringOnboardingButton)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outline-primary" block href="https://support.edx.org/hc/en-us/sections/115004169247-Taking-Timed-and-Proctored-Exams">
|
|
||||||
{intl.formatMessage(messages.proctoringReviewRequirementsButton)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ProctoringInfoPanel.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(ProctoringInfoPanel);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.outline-sidebar-proctoring-panel {
|
|
||||||
border: 1px solid $dark-500;
|
|
||||||
border-top: 5px solid $brand-600;
|
|
||||||
}
|
|
||||||
.proctoring-onboarding-success {
|
|
||||||
border-top: 5px solid $primary-500;
|
|
||||||
}
|
|
||||||
.proctoring-onboarding-submitted {
|
|
||||||
border-top: 5px solid $dark-500;
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,7 @@ function UpdateGoalSelector({
|
|||||||
<section className="mb-4">
|
<section className="mb-4">
|
||||||
<div className="row w-100 m-0">
|
<div className="row w-100 m-0">
|
||||||
<div className="col-12 p-0">
|
<div className="col-12 p-0">
|
||||||
<label className="h4 m-0" htmlFor="edit-goal-selector">
|
<label className="h6 m-0" htmlFor="edit-goal-selector">
|
||||||
{intl.formatMessage(messages.goal)}
|
{intl.formatMessage(messages.goal)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Button } from '@edx/paragon';
|
import { Button } from '@edx/paragon';
|
||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
import { UpgradeButton } from '../../../generic/upgrade-button';
|
import VerifiedCert from '../../../generic/assets/edX_verified_certificate.png';
|
||||||
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
|
|
||||||
|
|
||||||
function UpgradeCard({ courseId, intl, onLearnMore }) {
|
function UpgradeCard({ courseId, intl, onLearnMore }) {
|
||||||
const { org } = useModel('courseHomeMeta', courseId);
|
const { org } = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
offer,
|
|
||||||
verifiedMode,
|
verifiedMode,
|
||||||
} = useModel('outline', courseId);
|
} = useModel('outline', courseId);
|
||||||
|
|
||||||
@@ -26,63 +24,47 @@ function UpgradeCard({ courseId, intl, onLearnMore }) {
|
|||||||
courserun_key: courseId,
|
courserun_key: courseId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const promotionEventProperties = {
|
|
||||||
creative: 'sidebarupsell',
|
|
||||||
name: 'In-Course Verification Prompt',
|
|
||||||
position: 'sidebar-message',
|
|
||||||
promotion_id: 'courseware_verified_certificate_upsell',
|
|
||||||
...eventProperties,
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
|
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
|
||||||
sendTrackEvent('Promotion Viewed', promotionEventProperties);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const logClick = () => {
|
const logClick = () => {
|
||||||
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
|
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
|
||||||
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
|
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
|
||||||
...eventProperties,
|
|
||||||
location: 'sidebar-message',
|
location: 'sidebar-message',
|
||||||
});
|
});
|
||||||
sendTrackEvent('Promotion Clicked', promotionEventProperties);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
|
<section className="mb-4 p-4 outline-sidebar-upgrade-card">
|
||||||
<h2 className="h4" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
|
<h2 className="h6" id="outline-sidebar-upgrade-header">{intl.formatMessage(messages.upgradeTitle)}</h2>
|
||||||
<div className="row w-100 m-0">
|
|
||||||
<div className="col-6 col-md-12 col-lg-3 col-xl-4 p-0 text-md-center text-lg-left">
|
|
||||||
<img
|
<img
|
||||||
alt={intl.formatMessage(messages.certAlt)}
|
alt={intl.formatMessage(messages.certAlt)}
|
||||||
className="w-100"
|
|
||||||
src={VerifiedCert}
|
src={VerifiedCert}
|
||||||
style={{ maxWidth: '10rem' }}
|
style={{ width: '124px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="float-right d-flex flex-column align-items-center">
|
||||||
<div className="col-6 col-md-12 col-lg-9 col-xl-8 p-0 pl-lg-2 text-center mt-md-2 mt-lg-0">
|
<Button
|
||||||
<div className="row w-100 m-0 justify-content-center">
|
variant="success"
|
||||||
<UpgradeButton
|
href={verifiedMode.upgradeUrl}
|
||||||
offer={offer}
|
|
||||||
onClick={logClick}
|
onClick={logClick}
|
||||||
verifiedMode={verifiedMode}
|
>
|
||||||
/>
|
{intl.formatMessage(messages.upgradeButton, {
|
||||||
|
price: verifiedMode.price,
|
||||||
|
symbol: verifiedMode.currencySymbol,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
{onLearnMore && (
|
{onLearnMore && (
|
||||||
<div className="col-12">
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="pb-0"
|
|
||||||
onClick={onLearnMore}
|
onClick={onLearnMore}
|
||||||
aria-labelledby="outline-sidebar-upgrade-header"
|
aria-labelledby="outline-sidebar-upgrade-header"
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.learnMore)}
|
{intl.formatMessage(messages.learnMore)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.outline-sidebar-upgrade-card {
|
.outline-sidebar-upgrade-card {
|
||||||
border: 1px solid $dark-500;
|
border: 1px solid $gray-500;
|
||||||
border-top: 5px solid $dark-500;
|
border-top: 5px solid $success-500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ function WelcomeMessage({ courseId, intl }) {
|
|||||||
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
|
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
|
||||||
{showShortMessage ? (
|
{showShortMessage ? (
|
||||||
<LmsHtmlFragment
|
<LmsHtmlFragment
|
||||||
className="inline-link"
|
|
||||||
data-testid="short-welcome-message-iframe"
|
data-testid="short-welcome-message-iframe"
|
||||||
key="short-html"
|
key="short-html"
|
||||||
html={shortWelcomeMessageHtml}
|
html={shortWelcomeMessageHtml}
|
||||||
@@ -63,7 +62,6 @@ function WelcomeMessage({ courseId, intl }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LmsHtmlFragment
|
<LmsHtmlFragment
|
||||||
className="inline-link"
|
|
||||||
data-testid="long-welcome-message-iframe"
|
data-testid="long-welcome-message-iframe"
|
||||||
key="full-html"
|
key="full-html"
|
||||||
html={welcomeMessageHtml}
|
html={welcomeMessageHtml}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { requestCert } from '../data/thunks';
|
|||||||
|
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import VerifiedCert from '../../generic/assets/edX_certificate.png';
|
import VerifiedCert from '../../generic/assets/edX_verified_certificate.png';
|
||||||
|
|
||||||
function CertificateBanner({ intl }) {
|
function CertificateBanner({ intl }) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ class CoursewareContainer extends Component {
|
|||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
unitId={routeUnitId}
|
unitId={routeUnitId}
|
||||||
courseStatus={courseStatus}
|
courseStatus={courseStatus}
|
||||||
metadataModel="coursewareMeta"
|
|
||||||
>
|
>
|
||||||
<Course
|
<Course
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
@@ -297,7 +296,7 @@ CoursewareContainer.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentCourseSelector = createSelector(
|
const currentCourseSelector = createSelector(
|
||||||
(state) => state.models.coursewareMeta || {},
|
(state) => state.models.courses || {},
|
||||||
(state) => state.courseware.courseId,
|
(state) => state.courseware.courseId,
|
||||||
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function Course({
|
|||||||
previousSequenceHandler,
|
previousSequenceHandler,
|
||||||
unitNavigationHandler,
|
unitNavigationHandler,
|
||||||
}) {
|
}) {
|
||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('courses', courseId);
|
||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||||
|
|
||||||
@@ -35,18 +35,16 @@ function Course({
|
|||||||
].filter(element => element != null).map(element => element.title);
|
].filter(element => element != null).map(element => element.title);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accessExpiration,
|
|
||||||
canShowUpgradeSock,
|
canShowUpgradeSock,
|
||||||
celebrations,
|
celebrations,
|
||||||
offer,
|
courseExpiredMessage,
|
||||||
org,
|
offerHtml,
|
||||||
userTimezone,
|
|
||||||
verifiedMode,
|
verifiedMode,
|
||||||
} = course;
|
} = course;
|
||||||
|
|
||||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||||
const offerAlert = useOfferAlert(offer, userTimezone, 'course');
|
const offerAlert = useOfferAlert(offerHtml, 'course');
|
||||||
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'course');
|
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredMessage, 'course');
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||||
@@ -61,22 +59,18 @@ function Course({
|
|||||||
const match = document.cookie.match(`${name}=([^;]*)`);
|
const match = document.cookie.match(`${name}=([^;]*)`);
|
||||||
return match ? match[1] : undefined;
|
return match ? match[1] : undefined;
|
||||||
};
|
};
|
||||||
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
|
const [REV1512FlyoverVisible, setREV1512FlyoverVisible] = useState(getCookie('REV1512FlyoverVisible') === 'true');
|
||||||
const isMobile = Boolean(
|
|
||||||
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
|
|
||||||
);
|
|
||||||
const [REV1512FlyoverVisible, setREV1512FlyoverVisible] = useState(isMobile ? false : !(getCookie(`REV1512FlyoverVisible${courseId}`) === 'false'));
|
|
||||||
const isREV1512FlyoverVisible = () => REV1512FlyoverEnabled && (REV1512FlyoverVisible || getCookie('REV1512FlyoverVisible') === 'true');
|
const isREV1512FlyoverVisible = () => REV1512FlyoverEnabled && (REV1512FlyoverVisible || getCookie('REV1512FlyoverVisible') === 'true');
|
||||||
const toggleREV1512Flyover = () => {
|
const toggleREV1512Flyover = () => {
|
||||||
const setCookie = (cookieName, value, domain) => {
|
const setCookie = (cookieName, value, domain, exdays) => {
|
||||||
const cookieDomain = (typeof domain === 'undefined') ? '' : `domain=${domain};`;
|
const cookieDomain = (typeof domain === 'undefined') ? '' : `domain=${domain};`;
|
||||||
const exdate = new Date();
|
const exdate = new Date();
|
||||||
exdate.setHours(exdate.getHours() + 4);
|
exdate.setDate(exdate.getDate() + exdays);
|
||||||
const cookieValue = `${escape(value)}; expires=${exdate.toUTCString()}`;
|
const cookieValue = escape(value) + ((exdays == null) ? '' : `; expires=${exdate.toUTCString()}`);
|
||||||
document.cookie = `${cookieName}=${cookieValue};${cookieDomain}path=/`;
|
document.cookie = `${cookieName}=${cookieValue};${cookieDomain}path=/`;
|
||||||
};
|
};
|
||||||
const isVisible = isREV1512FlyoverVisible();
|
const isVisible = isREV1512FlyoverVisible();
|
||||||
setCookie(`REV1512FlyoverVisible${courseId}`, !isVisible);
|
setCookie('REV1512FlyoverVisible', !isVisible);
|
||||||
setREV1512FlyoverVisible(!isVisible);
|
setREV1512FlyoverVisible(!isVisible);
|
||||||
};
|
};
|
||||||
// The above block of code should be reverted after the REV1512 experiment
|
// The above block of code should be reverted after the REV1512 experiment
|
||||||
@@ -100,18 +94,15 @@ function Course({
|
|||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
|
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
|
||||||
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
||||||
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* This line should be reverted after REV1512 experiment */
|
|
||||||
/>
|
/>
|
||||||
<AlertList topic="sequence" />
|
<AlertList topic="sequence" />
|
||||||
<Sequence
|
<Sequence
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
sectionId={section ? section.id : null}
|
|
||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
unitNavigationHandler={unitNavigationHandler}
|
unitNavigationHandler={unitNavigationHandler}
|
||||||
nextSequenceHandler={nextSequenceHandler}
|
nextSequenceHandler={nextSequenceHandler}
|
||||||
previousSequenceHandler={previousSequenceHandler}
|
previousSequenceHandler={previousSequenceHandler}
|
||||||
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
|
|
||||||
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* This line should be reverted after REV1512 experiment */
|
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* This line should be reverted after REV1512 experiment */
|
||||||
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
||||||
/>
|
/>
|
||||||
@@ -121,15 +112,7 @@ function Course({
|
|||||||
open
|
open
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canShowUpgradeSock && (
|
{canShowUpgradeSock && <CourseSock verifiedMode={verifiedMode} />}
|
||||||
<CourseSock
|
|
||||||
courseId={courseId}
|
|
||||||
offer={offer}
|
|
||||||
orgKey={org}
|
|
||||||
pageLocation="Course Content Page"
|
|
||||||
verifiedMode={verifiedMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ContentTools course={course} />
|
<ContentTools course={course} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('Course', () => {
|
|||||||
const { models } = store.getState();
|
const { models } = store.getState();
|
||||||
const sequence = models.sequences[mockData.sequenceId];
|
const sequence = models.sequences[mockData.sequenceId];
|
||||||
const section = models.sections[sequence.sectionId];
|
const section = models.sections[sequence.sectionId];
|
||||||
const course = models.coursewareMeta[mockData.courseId];
|
const course = models.courses[mockData.courseId];
|
||||||
expect(document.title).toMatch(
|
expect(document.title).toMatch(
|
||||||
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
`${sequence.title} | ${section.title} | ${course.title} | edX`,
|
||||||
);
|
);
|
||||||
@@ -85,27 +85,23 @@ describe('Course', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays offer and expiration alert', async () => {
|
it('displays offer and expiration alert', async () => {
|
||||||
|
const offerText = 'test-offer';
|
||||||
|
const offerId = `${offerText}-id`;
|
||||||
|
const offerHtml = `<div data-testid="${offerId}">${offerText}</div>`;
|
||||||
|
|
||||||
|
const expirationText = 'test-expiration';
|
||||||
|
const expirationId = `${expirationText}-id`;
|
||||||
|
const expirationHtml = `<div data-testid="${expirationId}">${expirationText}</div>`;
|
||||||
|
|
||||||
const courseMetadata = Factory.build('courseMetadata', {
|
const courseMetadata = Factory.build('courseMetadata', {
|
||||||
access_expiration: {
|
offer_html: offerHtml,
|
||||||
expiration_date: '2080-01-01T12:00:00Z',
|
course_expired_message: expirationHtml,
|
||||||
masquerading_expired_course: false,
|
|
||||||
upgrade_deadline: null,
|
|
||||||
upgrade_url: null,
|
|
||||||
},
|
|
||||||
offer: {
|
|
||||||
code: 'EDXWELCOME',
|
|
||||||
expiration_date: '2070-01-01T12:00:00Z',
|
|
||||||
original_price: '$100',
|
|
||||||
discounted_price: '$85',
|
|
||||||
percentage: 15,
|
|
||||||
upgrade_url: 'https://example.com/upgrade',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
||||||
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
||||||
|
|
||||||
await screen.findByText('EDXWELCOME');
|
expect(await screen.findByTestId(offerId)).toHaveTextContent(offerText);
|
||||||
await screen.findByText('Audit Access Expires');
|
expect(screen.getByTestId(expirationId)).toHaveTextContent(expirationText);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes handlers to the sequence', async () => {
|
it('passes handlers to the sequence', async () => {
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ export default function CourseBreadcrumbs({
|
|||||||
sequenceId,
|
sequenceId,
|
||||||
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
|
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
|
||||||
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
||||||
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
}) {
|
}) {
|
||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('courses', courseId);
|
||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const section = useModel('sections', sectionId);
|
const section = useModel('sections', sectionId);
|
||||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||||
@@ -57,12 +56,6 @@ export default function CourseBreadcrumbs({
|
|||||||
return [];
|
return [];
|
||||||
}, [courseStatus, sequenceStatus]);
|
}, [courseStatus, sequenceStatus]);
|
||||||
|
|
||||||
// These should be reverted after the REV1512 experiment
|
|
||||||
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
|
|
||||||
const isMobile = Boolean(
|
|
||||||
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="breadcrumb" className="my-4">
|
<nav aria-label="breadcrumb" className="my-4">
|
||||||
<ol className="list-unstyled d-flex m-0">
|
<ol className="list-unstyled d-flex m-0">
|
||||||
@@ -93,11 +86,11 @@ export default function CourseBreadcrumbs({
|
|||||||
))}
|
))}
|
||||||
{/* The below block of code should be reverted after the REV1512 experiment */}
|
{/* The below block of code should be reverted after the REV1512 experiment */}
|
||||||
{REV1512FlyoverEnabled
|
{REV1512FlyoverEnabled
|
||||||
&& !isMobile && (
|
&& (
|
||||||
<div
|
<div
|
||||||
className="toggleFlyoverButton"
|
className="toggleFlyoverButton"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ marginLeft: 'auto', marginTop: '-16px', borderBottom: isREV1512FlyoverVisible() ? '2px solid #00262b' : 'none' }}
|
style={{ marginLeft: 'auto', marginTop: '-16px' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toggleREV1512Flyover();
|
toggleREV1512Flyover();
|
||||||
}}
|
}}
|
||||||
@@ -118,7 +111,6 @@ CourseBreadcrumbs.propTypes = {
|
|||||||
sequenceId: PropTypes.string,
|
sequenceId: PropTypes.string,
|
||||||
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||||
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||||
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CourseBreadcrumbs.defaultProps = {
|
CourseBreadcrumbs.defaultProps = {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useModel } from '../../../generic/model-store';
|
|||||||
function CelebrationModal({
|
function CelebrationModal({
|
||||||
courseId, intl, open, ...rest
|
courseId, intl, open, ...rest
|
||||||
}) {
|
}) {
|
||||||
const { org } = useModel('coursewareMeta', courseId);
|
const { org } = useModel('courses', courseId);
|
||||||
|
|
||||||
const layout = layoutGenerator({
|
const layout = layoutGenerator({
|
||||||
mobile: 0,
|
mobile: 0,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir
|
|||||||
|
|
||||||
// Update our local copy of course data from LMS
|
// Update our local copy of course data from LMS
|
||||||
dispatch(updateModel({
|
dispatch(updateModel({
|
||||||
modelType: 'coursewareMeta',
|
modelType: 'courses',
|
||||||
model: {
|
model: {
|
||||||
id: courseId,
|
id: courseId,
|
||||||
celebrations: {
|
celebrations: {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
import {
|
|
||||||
FormattedMessage, injectIntl, intlShape,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { Hyperlink } from '@edx/paragon';
|
|
||||||
import { faSearch } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
|
||||||
|
|
||||||
import messages from './messages';
|
|
||||||
import { logClick } from './utils';
|
|
||||||
|
|
||||||
function CatalogSuggestion({ intl, variant }) {
|
|
||||||
const { courseId } = useSelector(state => state.courseware);
|
|
||||||
const { org } = useModel('coursewareMeta', courseId);
|
|
||||||
const { administrator } = getAuthenticatedUser();
|
|
||||||
|
|
||||||
const searchOurCatalogLink = (
|
|
||||||
<Hyperlink
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
destination={getConfig().SEARCH_CATALOG_URL}
|
|
||||||
className="text-reset"
|
|
||||||
onClick={() => logClick(org, courseId, administrator, 'catalog_search', { variant })}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.searchOurCatalogLink)}
|
|
||||||
</Hyperlink>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="row w-100 mx-0 my-2 justify-content-center">
|
|
||||||
<div className="col col-md-8 p-4 bg-info-100 text-center">
|
|
||||||
<FontAwesomeIcon icon={faSearch} style={{ width: '20px' }} />
|
|
||||||
<FormattedMessage
|
|
||||||
id="courseExit.catalogSearchSuggestion"
|
|
||||||
defaultMessage="Looking to learn more? {searchOurCatalogLink} to find more courses and programs to explore."
|
|
||||||
values={{ searchOurCatalogLink }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CatalogSuggestion.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
variant: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(CatalogSuggestion);
|
|
||||||
@@ -12,12 +12,10 @@ import { Alert, Button, Hyperlink } from '@edx/paragon';
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
|
|
||||||
import CatalogSuggestion from './CatalogSuggestion';
|
|
||||||
import CelebrationMobile from './assets/celebration_456x328.gif';
|
import CelebrationMobile from './assets/celebration_456x328.gif';
|
||||||
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
import CelebrationDesktop from './assets/celebration_750x540.gif';
|
||||||
import certificate from '../../../generic/assets/edX_certificate.png';
|
import certificate from '../../../generic/assets/edX_verified_certificate.png';
|
||||||
import certificateLocked from '../../../generic/assets/edX_locked_certificate.png';
|
import certificateLocked from '../../../generic/assets/edX_locked_verified_certificate.png';
|
||||||
import { FormattedPricing } from '../../../generic/upgrade-button';
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
import { requestCert } from '../../../course-home/data/thunks';
|
import { requestCert } from '../../../course-home/data/thunks';
|
||||||
@@ -44,14 +42,12 @@ function CourseCelebration({ intl }) {
|
|||||||
certificateData,
|
certificateData,
|
||||||
end,
|
end,
|
||||||
linkedinAddToProfileUrl,
|
linkedinAddToProfileUrl,
|
||||||
offer,
|
|
||||||
org,
|
org,
|
||||||
relatedPrograms,
|
relatedPrograms,
|
||||||
title,
|
|
||||||
verifiedMode,
|
verifiedMode,
|
||||||
verifyIdentityUrl,
|
verifyIdentityUrl,
|
||||||
verificationStatus,
|
verificationStatus,
|
||||||
} = useModel('coursewareMeta', courseId);
|
} = useModel('courses', courseId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
certStatus,
|
certStatus,
|
||||||
@@ -89,12 +85,10 @@ function CourseCelebration({ intl }) {
|
|||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
);
|
);
|
||||||
|
|
||||||
let buttonPrefix = null;
|
|
||||||
let buttonLocation;
|
let buttonLocation;
|
||||||
let buttonText;
|
let buttonText;
|
||||||
let buttonVariant = 'outline-primary';
|
let buttonVariant = 'outline-primary';
|
||||||
let buttonEvent = null;
|
let buttonEvent = null;
|
||||||
let buttonSuffix = null;
|
|
||||||
let certificateImage = certificate;
|
let certificateImage = certificate;
|
||||||
let footnote;
|
let footnote;
|
||||||
let message;
|
let message;
|
||||||
@@ -123,22 +117,9 @@ function CourseCelebration({ intl }) {
|
|||||||
buttonLocation = downloadUrl;
|
buttonLocation = downloadUrl;
|
||||||
buttonText = intl.formatMessage(messages.downloadButton);
|
buttonText = intl.formatMessage(messages.downloadButton);
|
||||||
}
|
}
|
||||||
if (linkedinAddToProfileUrl) {
|
|
||||||
buttonPrefix = (
|
|
||||||
<Button
|
|
||||||
className="mr-3"
|
|
||||||
href={linkedinAddToProfileUrl}
|
|
||||||
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
|
|
||||||
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
|
|
||||||
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
buttonEvent = 'view_cert';
|
buttonEvent = 'view_cert';
|
||||||
visitEvent = 'celebration_with_cert';
|
visitEvent = 'celebration_with_cert';
|
||||||
footnote = <DashboardFootnote variant={visitEvent} />;
|
footnote = <DashboardFootnote />;
|
||||||
break;
|
break;
|
||||||
case 'earned_but_not_available': {
|
case 'earned_but_not_available': {
|
||||||
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
|
||||||
@@ -165,33 +146,21 @@ function CourseCelebration({ intl }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
visitEvent = 'celebration_with_unavailable_cert';
|
visitEvent = 'celebration_with_unavailable_cert';
|
||||||
footnote = <DashboardFootnote variant={visitEvent} />;
|
footnote = <DashboardFootnote />;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'requesting':
|
case 'requesting':
|
||||||
// The requesting status needs a different button because it does a POST instead of a GET.
|
buttonText = intl.formatMessage(messages.requestCertificateButton);
|
||||||
// So we don't set buttonLocation and instead define a custom button as a buttonPrefix.
|
|
||||||
buttonEvent = 'request_cert';
|
buttonEvent = 'request_cert';
|
||||||
buttonPrefix = (
|
|
||||||
<Button
|
|
||||||
variant={buttonVariant}
|
|
||||||
onClick={() => {
|
|
||||||
logClick(org, courseId, administrator, buttonEvent);
|
|
||||||
dispatch(requestCert(courseId));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.requestCertificateButton)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
certHeader = intl.formatMessage(messages.certificateHeaderRequestable);
|
certHeader = intl.formatMessage(messages.certificateHeaderRequestable);
|
||||||
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
|
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
|
||||||
visitEvent = 'celebration_with_requestable_cert';
|
visitEvent = 'celebration_with_requestable_cert';
|
||||||
footnote = <DashboardFootnote variant={visitEvent} />;
|
footnote = <DashboardFootnote />;
|
||||||
break;
|
break;
|
||||||
case 'unverified':
|
case 'unverified':
|
||||||
certHeader = intl.formatMessage(messages.certificateHeaderUnverified);
|
certHeader = intl.formatMessage(messages.certificateHeaderUnverified);
|
||||||
visitEvent = 'celebration_unverified';
|
visitEvent = 'celebration_unverified';
|
||||||
footnote = <DashboardFootnote variant={visitEvent} />;
|
footnote = <DashboardFootnote />;
|
||||||
if (verificationStatus === 'pending') {
|
if (verificationStatus === 'pending') {
|
||||||
message = (<p>{intl.formatMessage(messages.verificationPending)}</p>);
|
message = (<p>{intl.formatMessage(messages.verificationPending)}</p>);
|
||||||
} else {
|
} else {
|
||||||
@@ -223,7 +192,7 @@ function CourseCelebration({ intl }) {
|
|||||||
assignments in this course. Upon completion, you will receive a verified certificate which is a
|
assignments in this course. Upon completion, you will receive a verified certificate which is a
|
||||||
valuable credential to improve your job prospects and advance your career, or highlight your
|
valuable credential to improve your job prospects and advance your career, or highlight your
|
||||||
certificate in school applications."
|
certificate in school applications."
|
||||||
values={{ price: <FormattedPricing inline offer={offer} verifiedMode={verifiedMode} /> }}
|
values={{ price: verifiedMode.currencySymbol + verifiedMode.price }}
|
||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
{getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE && (
|
{getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE && (
|
||||||
@@ -241,29 +210,13 @@ function CourseCelebration({ intl }) {
|
|||||||
buttonEvent = 'upgrade';
|
buttonEvent = 'upgrade';
|
||||||
buttonLocation = verifiedMode.upgradeUrl;
|
buttonLocation = verifiedMode.upgradeUrl;
|
||||||
buttonVariant = 'primary';
|
buttonVariant = 'primary';
|
||||||
if (offer) {
|
|
||||||
buttonSuffix = (
|
|
||||||
<span className="ml-2 align-middle">
|
|
||||||
<FormattedMessage
|
|
||||||
id="courseCelebration.upgradeDiscountCodePrompt"
|
|
||||||
defaultMessage="Use code {code} at checkout for {percent}% off!"
|
|
||||||
values={{
|
|
||||||
code: (<b>{offer.code}</b>),
|
|
||||||
percent: offer.percentage,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
certificateImage = certificateLocked;
|
certificateImage = certificateLocked;
|
||||||
visitEvent = 'celebration_upgrade';
|
visitEvent = 'celebration_upgrade';
|
||||||
if (verifiedMode.accessExpirationDate) {
|
if (verifiedMode.accessExpirationDate) {
|
||||||
footnote = <UpgradeFootnote deadline={verifiedMode.accessExpirationDate} href={verifiedMode.upgradeUrl} />;
|
footnote = <UpgradeFootnote deadline={verifiedMode.accessExpirationDate} href={verifiedMode.upgradeUrl} />;
|
||||||
} else {
|
} else {
|
||||||
footnote = <DashboardFootnote variant={visitEvent} />;
|
footnote = <DashboardFootnote />;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
visitEvent = 'celebration_audit_no_upgrade';
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -275,7 +228,7 @@ function CourseCelebration({ intl }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`${intl.formatMessage(messages.congratulationsHeader)} | ${title} | ${getConfig().SITE_NAME}`}</title>
|
<title>{`${intl.formatMessage(messages.congratulationsHeader)} | ${getConfig().SITE_NAME}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light">
|
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light">
|
||||||
<div className="col-12 p-0 h2 text-center">
|
<div className="col-12 p-0 h2 text-center">
|
||||||
@@ -314,10 +267,32 @@ function CourseCelebration({ intl }) {
|
|||||||
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
|
||||||
<div className="h4">{certHeader}</div>
|
<div className="h4">{certHeader}</div>
|
||||||
{message}
|
{message}
|
||||||
<div className="mt-2">
|
{/* The requesting status needs a different button because it does a POST instead of a GET */}
|
||||||
{buttonPrefix}
|
{certStatus === 'requesting' && (
|
||||||
|
<Button
|
||||||
|
variant={buttonVariant}
|
||||||
|
onClick={() => {
|
||||||
|
logClick(org, courseId, administrator, buttonEvent);
|
||||||
|
dispatch(requestCert(courseId));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{certStatus === 'downloadable' && linkedinAddToProfileUrl && (
|
||||||
|
<Button
|
||||||
|
className="mr-3 mt-2"
|
||||||
|
href={linkedinAddToProfileUrl}
|
||||||
|
onClick={() => logClick(org, courseId, administrator, 'linkedin_add_to_profile')}
|
||||||
|
style={{ backgroundColor: LINKEDIN_BLUE, border: 'none' }}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLinkedinIn} className="mr-3" />
|
||||||
|
{`${intl.formatMessage(messages.linkedinAddToProfileButton)}`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{buttonLocation && (
|
{buttonLocation && (
|
||||||
<Button
|
<Button
|
||||||
|
className="mt-2"
|
||||||
variant={buttonVariant}
|
variant={buttonVariant}
|
||||||
href={buttonLocation}
|
href={buttonLocation}
|
||||||
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
|
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
|
||||||
@@ -325,8 +300,6 @@ function CourseCelebration({ intl }) {
|
|||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{buttonSuffix}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{certStatus !== 'unverified' && (
|
{certStatus !== 'unverified' && (
|
||||||
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
|
||||||
@@ -350,7 +323,6 @@ function CourseCelebration({ intl }) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{footnote}
|
{footnote}
|
||||||
<CatalogSuggestion variant={visitEvent} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useSelector } from 'react-redux';
|
|||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import CourseCelebration from './CourseCelebration';
|
import CourseCelebration from './CourseCelebration';
|
||||||
import CourseInProgress from './CourseInProgress';
|
|
||||||
import CourseNonPassing from './CourseNonPassing';
|
import CourseNonPassing from './CourseNonPassing';
|
||||||
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
|
import { COURSE_EXIT_MODES, getCourseExitMode } from './utils';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -19,8 +18,6 @@ function CourseExit({ intl }) {
|
|||||||
let body = null;
|
let body = null;
|
||||||
if (mode === COURSE_EXIT_MODES.nonPassing) {
|
if (mode === COURSE_EXIT_MODES.nonPassing) {
|
||||||
body = (<CourseNonPassing />);
|
body = (<CourseNonPassing />);
|
||||||
} else if (mode === COURSE_EXIT_MODES.inProgress) {
|
|
||||||
body = (<CourseInProgress />);
|
|
||||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||||
body = (<CourseCelebration />);
|
body = (<CourseCelebration />);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import initializeStore from '../../../store';
|
|||||||
import executeThunk from '../../../utils';
|
import executeThunk from '../../../utils';
|
||||||
import CourseCelebration from './CourseCelebration';
|
import CourseCelebration from './CourseCelebration';
|
||||||
import CourseExit from './CourseExit';
|
import CourseExit from './CourseExit';
|
||||||
import CourseInProgress from './CourseInProgress';
|
|
||||||
import CourseNonPassing from './CourseNonPassing';
|
import CourseNonPassing from './CourseNonPassing';
|
||||||
|
|
||||||
initializeMockApp();
|
initializeMockApp();
|
||||||
@@ -56,9 +55,6 @@ describe('Course Exit Pages', () => {
|
|||||||
cert_status: 'downloadable',
|
cert_status: 'downloadable',
|
||||||
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
cert_web_view_url: '/certificates/cooluuidgoeshere',
|
||||||
},
|
},
|
||||||
enrollment: {
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await fetchAndRender(<CourseExit />);
|
await fetchAndRender(<CourseExit />);
|
||||||
expect(screen.getByText('Congratulations!')).toBeInTheDocument();
|
expect(screen.getByText('Congratulations!')).toBeInTheDocument();
|
||||||
@@ -69,9 +65,6 @@ describe('Course Exit Pages', () => {
|
|||||||
certificate_data: {
|
certificate_data: {
|
||||||
cert_status: 'unverified',
|
cert_status: 'unverified',
|
||||||
},
|
},
|
||||||
enrollment: {
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
user_has_passing_grade: false,
|
user_has_passing_grade: false,
|
||||||
});
|
});
|
||||||
await fetchAndRender(<CourseExit />);
|
await fetchAndRender(<CourseExit />);
|
||||||
@@ -294,17 +287,4 @@ describe('Course Exit Pages', () => {
|
|||||||
expect(screen.getByRole('link', { name: 'View grades' })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: 'View grades' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Course in progress experience', () => {
|
|
||||||
it('Displays link to dates tab', async () => {
|
|
||||||
setMetadata({ user_has_passing_grade: false });
|
|
||||||
const courseBlocks = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name,
|
|
||||||
{ hasScheduledContent: true });
|
|
||||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
|
||||||
|
|
||||||
await fetchAndRender(<CourseInProgress />);
|
|
||||||
expect(screen.getByText('More content is coming soon!')).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: 'View course schedule' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { Alert, Button } from '@edx/paragon';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
|
||||||
|
|
||||||
import CatalogSuggestion from './CatalogSuggestion';
|
|
||||||
import DashboardFootnote from './DashboardFootnote';
|
|
||||||
import messages from './messages';
|
|
||||||
import { logClick, logVisit } from './utils';
|
|
||||||
|
|
||||||
function CourseInProgress({ intl }) {
|
|
||||||
const { courseId } = useSelector(state => state.courseware);
|
|
||||||
const { org, tabs, title } = useModel('coursewareMeta', courseId);
|
|
||||||
const { administrator } = getAuthenticatedUser();
|
|
||||||
|
|
||||||
// Get dates tab link for 'view course schedule' button
|
|
||||||
const datesTab = tabs.find(tab => tab.slug === 'dates');
|
|
||||||
const datesTabLink = datesTab && datesTab.url;
|
|
||||||
|
|
||||||
useEffect(() => logVisit(org, courseId, administrator, 'in_progress'), [org, courseId, administrator]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>{`${intl.formatMessage(messages.endOfCourseTitle)} | ${title} | ${getConfig().SITE_NAME}`}</title>
|
|
||||||
</Helmet>
|
|
||||||
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light justify-content-center">
|
|
||||||
<div className="col-12 p-0 h2 text-center">
|
|
||||||
{ intl.formatMessage(messages.courseInProgressHeader) }
|
|
||||||
</div>
|
|
||||||
<Alert variant="primary" className="col col-lg-10 mt-4 d-flex">
|
|
||||||
<div className="row w-100 m-0 align-items-start">
|
|
||||||
<div className="flex-grow-1 col-md p-0">{ intl.formatMessage(messages.courseInProgressDescription) }</div>
|
|
||||||
{datesTabLink && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="flex-shrink-0 mt-3 mt-md-0 mb-1 mb-md-0 ml-md-5"
|
|
||||||
href={datesTabLink}
|
|
||||||
onClick={() => logClick(org, courseId, administrator, 'view_dates_tab')}
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.viewCourseScheduleButton)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
<DashboardFootnote variant="in_progress" />
|
|
||||||
<CatalogSuggestion variant="in_progress" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CourseInProgress.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(CourseInProgress);
|
|
||||||
@@ -9,14 +9,13 @@ import { getConfig } from '@edx/frontend-platform';
|
|||||||
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
import CatalogSuggestion from './CatalogSuggestion';
|
|
||||||
import DashboardFootnote from './DashboardFootnote';
|
import DashboardFootnote from './DashboardFootnote';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { logClick, logVisit } from './utils';
|
import { logClick, logVisit } from './utils';
|
||||||
|
|
||||||
function CourseNonPassing({ intl }) {
|
function CourseNonPassing({ intl }) {
|
||||||
const { courseId } = useSelector(state => state.courseware);
|
const { courseId } = useSelector(state => state.courseware);
|
||||||
const { org, tabs, title } = useModel('coursewareMeta', courseId);
|
const { org, tabs } = useModel('courses', courseId);
|
||||||
const { administrator } = getAuthenticatedUser();
|
const { administrator } = getAuthenticatedUser();
|
||||||
|
|
||||||
// Get progress tab link for 'view grades' button
|
// Get progress tab link for 'view grades' button
|
||||||
@@ -28,7 +27,7 @@ function CourseNonPassing({ intl }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`${intl.formatMessage(messages.endOfCourseTitle)} | ${title} | ${getConfig().SITE_NAME}`}</title>
|
<title>{`${intl.formatMessage(messages.endOfCourseTitle)} | ${getConfig().SITE_NAME}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light justify-content-center">
|
<div className="row w-100 mx-0 mb-4 px-5 py-4 border border-light justify-content-center">
|
||||||
<div className="col-12 p-0 h2 text-center">
|
<div className="col-12 p-0 h2 text-center">
|
||||||
@@ -40,7 +39,7 @@ function CourseNonPassing({ intl }) {
|
|||||||
{progressLink && (
|
{progressLink && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="flex-shrink-0 mt-3 mt-sm-0 mb-1 mb-sm-0 ml-sm-5"
|
className="flex-shrink-0 mt-3 mt-sm-0 mb-1 mb-sm-0 ml-2 ml-sm-5"
|
||||||
href={progressLink}
|
href={progressLink}
|
||||||
onClick={() => logClick(org, courseId, administrator, 'view_grades')}
|
onClick={() => logClick(org, courseId, administrator, 'view_grades')}
|
||||||
>
|
>
|
||||||
@@ -49,8 +48,7 @@ function CourseNonPassing({ intl }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
<DashboardFootnote variant="nonpassing" />
|
<DashboardFootnote />
|
||||||
<CatalogSuggestion variant="nonpassing" />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
import {
|
import {
|
||||||
FormattedMessage, injectIntl, intlShape,
|
FormattedMessage, injectIntl, intlShape,
|
||||||
} from '@edx/frontend-platform/i18n';
|
} from '@edx/frontend-platform/i18n';
|
||||||
@@ -10,23 +7,15 @@ import { Hyperlink } from '@edx/paragon';
|
|||||||
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
|
||||||
|
|
||||||
import Footnote from './Footnote';
|
import Footnote from './Footnote';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { logClick } from './utils';
|
|
||||||
|
|
||||||
function DashboardFootnote({ intl, variant }) {
|
|
||||||
const { courseId } = useSelector(state => state.courseware);
|
|
||||||
const { org } = useModel('coursewareMeta', courseId);
|
|
||||||
const { administrator } = getAuthenticatedUser();
|
|
||||||
|
|
||||||
|
function DashboardFootnote({ intl }) {
|
||||||
const dashboardLink = (
|
const dashboardLink = (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
style={{ textDecoration: 'underline' }}
|
style={{ textDecoration: 'underline' }}
|
||||||
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
|
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||||
className="text-reset"
|
className="text-reset"
|
||||||
onClick={() => logClick(org, courseId, administrator, 'dashboard_footnote', { variant })}
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.dashboardLink)}
|
{intl.formatMessage(messages.dashboardLink)}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
@@ -48,7 +37,6 @@ function DashboardFootnote({ intl, variant }) {
|
|||||||
|
|
||||||
DashboardFootnote.propTypes = {
|
DashboardFootnote.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
variant: PropTypes.string.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(DashboardFootnote);
|
export default injectIntl(DashboardFootnote);
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import PropTypes from 'prop-types';
|
|||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
import { Alert, Button, Hyperlink } from '@edx/paragon';
|
||||||
import certImage from '../../../generic/assets/edX_certificate.png';
|
import microBachelorsCertImage from '../../../generic/assets/edX_microBachelors_certificate.png';
|
||||||
|
import microMastersCertImage from '../../../generic/assets/edX_microMasters_certificate.png';
|
||||||
|
import professionalCertImage from '../../../generic/assets/edX_professionalCertificate_certificate.png';
|
||||||
|
import xSeriesCertImage from '../../../generic/assets/edX_xSeries_certificate.png';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,8 +20,6 @@ import messages from './messages';
|
|||||||
* four aforementioned types. This will not impact the parent components (i.e. CourseCelebration will render normally).
|
* four aforementioned types. This will not impact the parent components (i.e. CourseCelebration will render normally).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const programTypes = ['microbachelors', 'micromasters', 'professional-certificate', 'xseries'];
|
|
||||||
|
|
||||||
function ProgramCompletion({
|
function ProgramCompletion({
|
||||||
intl,
|
intl,
|
||||||
progress,
|
progress,
|
||||||
@@ -26,7 +27,25 @@ function ProgramCompletion({
|
|||||||
type,
|
type,
|
||||||
url,
|
url,
|
||||||
}) {
|
}) {
|
||||||
if (!programTypes.includes(type) || progress.notStarted !== 0 || progress.inProgress !== 0) {
|
if (progress.notStarted !== 0 || progress.inProgress !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let certImage;
|
||||||
|
switch (type) {
|
||||||
|
case 'microbachelors':
|
||||||
|
certImage = microBachelorsCertImage;
|
||||||
|
break;
|
||||||
|
case 'micromasters':
|
||||||
|
certImage = microMastersCertImage;
|
||||||
|
break;
|
||||||
|
case 'professional-certificate':
|
||||||
|
certImage = professionalCertImage;
|
||||||
|
break;
|
||||||
|
case 'xseries':
|
||||||
|
certImage = xSeriesCertImage;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useModel } from '../../../generic/model-store';
|
|||||||
|
|
||||||
function UpgradeFootnote({ deadline, href, intl }) {
|
function UpgradeFootnote({ deadline, href, intl }) {
|
||||||
const { courseId } = useSelector(state => state.courseware);
|
const { courseId } = useSelector(state => state.courseware);
|
||||||
const { org } = useModel('coursewareMeta', courseId);
|
const { org } = useModel('courses', courseId);
|
||||||
const { administrator } = getAuthenticatedUser();
|
const { administrator } = getAuthenticatedUser();
|
||||||
|
|
||||||
const upgradeLink = (
|
const upgradeLink = (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import CourseExit from './CourseExit';
|
import CourseExit from './CourseExit';
|
||||||
import { getCourseExitNavigation } from './utils';
|
import { getCourseExitText } from './utils';
|
||||||
|
|
||||||
export { CourseExit, getCourseExitNavigation };
|
export { CourseExit, getCourseExitText };
|
||||||
|
|||||||
@@ -44,14 +44,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Four people raising their hands in celebration',
|
defaultMessage: 'Four people raising their hands in celebration',
|
||||||
description: 'Alt text used to describe celebratory image',
|
description: 'Alt text used to describe celebratory image',
|
||||||
},
|
},
|
||||||
courseInProgressDescription: {
|
|
||||||
id: 'courseExit.courseInProgressDescription',
|
|
||||||
defaultMessage: 'It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.',
|
|
||||||
},
|
|
||||||
courseInProgressHeader: {
|
|
||||||
id: 'courseExit.courseInProgressHeader',
|
|
||||||
defaultMessage: 'More content is coming soon!',
|
|
||||||
},
|
|
||||||
dashboardLink: {
|
dashboardLink: {
|
||||||
id: 'courseExit.dashboardLink',
|
id: 'courseExit.dashboardLink',
|
||||||
defaultMessage: 'Dashboard',
|
defaultMessage: 'Dashboard',
|
||||||
@@ -122,11 +114,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Request certificate',
|
defaultMessage: 'Request certificate',
|
||||||
description: 'Button to request the course certificate',
|
description: 'Button to request the course certificate',
|
||||||
},
|
},
|
||||||
searchOurCatalogLink: {
|
|
||||||
id: 'courseExit.searchOurCatalogLink',
|
|
||||||
defaultMessage: 'Search our catalog',
|
|
||||||
description: 'First part of a sentence that continues afterward',
|
|
||||||
},
|
|
||||||
shareHeader: {
|
shareHeader: {
|
||||||
id: 'courseCelebration.shareHeader',
|
id: 'courseCelebration.shareHeader',
|
||||||
defaultMessage: 'You have completed your course. Share your success on social media or email.',
|
defaultMessage: 'You have completed your course. Share your success on social media or email.',
|
||||||
@@ -162,11 +149,6 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'View my certificate',
|
defaultMessage: 'View my certificate',
|
||||||
description: 'Button to view the course certificate',
|
description: 'Button to view the course certificate',
|
||||||
},
|
},
|
||||||
viewCourseScheduleButton: {
|
|
||||||
id: 'courseExit.viewCourseScheduleButton',
|
|
||||||
defaultMessage: 'View course schedule',
|
|
||||||
description: 'Button to view the course schedule',
|
|
||||||
},
|
|
||||||
viewCoursesButton: {
|
viewCoursesButton: {
|
||||||
id: 'courseExit.viewCoursesButton',
|
id: 'courseExit.viewCoursesButton',
|
||||||
defaultMessage: 'View my courses',
|
defaultMessage: 'View my courses',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
|
||||||
|
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ const COURSE_EXIT_MODES = {
|
|||||||
disabled: 0,
|
disabled: 0,
|
||||||
celebration: 1,
|
celebration: 1,
|
||||||
nonPassing: 2,
|
nonPassing: 2,
|
||||||
inProgress: 3,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
// These are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
|
||||||
@@ -30,14 +28,10 @@ function getCourseExitMode(courseId) {
|
|||||||
const {
|
const {
|
||||||
certificateData,
|
certificateData,
|
||||||
courseExitPageIsActive,
|
courseExitPageIsActive,
|
||||||
hasScheduledContent,
|
|
||||||
isEnrolled,
|
|
||||||
userHasPassingGrade,
|
userHasPassingGrade,
|
||||||
} = useModel('coursewareMeta', courseId);
|
} = useModel('courses', courseId);
|
||||||
|
|
||||||
const authenticatedUser = getAuthenticatedUser();
|
if (!courseExitPageIsActive) {
|
||||||
|
|
||||||
if (!courseExitPageIsActive || !authenticatedUser || !isEnrolled) {
|
|
||||||
return COURSE_EXIT_MODES.disabled;
|
return COURSE_EXIT_MODES.disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +49,6 @@ function getCourseExitMode(courseId) {
|
|||||||
isEligibleForCertificate = NON_CERTIFICATE_STATUSES.indexOf(certStatus) === -1;
|
isEligibleForCertificate = NON_CERTIFICATE_STATUSES.indexOf(certStatus) === -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasScheduledContent && !userHasPassingGrade) {
|
|
||||||
return COURSE_EXIT_MODES.inProgress;
|
|
||||||
}
|
|
||||||
if (isEligibleForCertificate && !userHasPassingGrade) {
|
if (isEligibleForCertificate && !userHasPassingGrade) {
|
||||||
return COURSE_EXIT_MODES.nonPassing;
|
return COURSE_EXIT_MODES.nonPassing;
|
||||||
}
|
}
|
||||||
@@ -67,28 +58,21 @@ function getCourseExitMode(courseId) {
|
|||||||
return COURSE_EXIT_MODES.disabled;
|
return COURSE_EXIT_MODES.disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns null in order to render the default navigation text
|
// Returns null if course exit is either not active or not handling the current case
|
||||||
function getCourseExitNavigation(courseId, intl) {
|
function getCourseExitText(courseId, intl) {
|
||||||
const exitMode = getCourseExitMode(courseId);
|
switch (getCourseExitMode(courseId)) {
|
||||||
const exitActive = exitMode !== COURSE_EXIT_MODES.disabled;
|
|
||||||
|
|
||||||
let exitText;
|
|
||||||
switch (exitMode) {
|
|
||||||
case COURSE_EXIT_MODES.celebration:
|
case COURSE_EXIT_MODES.celebration:
|
||||||
exitText = intl.formatMessage(messages.nextButtonComplete);
|
return intl.formatMessage(messages.nextButtonComplete);
|
||||||
break;
|
|
||||||
case COURSE_EXIT_MODES.nonPassing:
|
case COURSE_EXIT_MODES.nonPassing:
|
||||||
exitText = intl.formatMessage(messages.nextButtonEnd);
|
return intl.formatMessage(messages.nextButtonEnd);
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
exitText = null;
|
return null;
|
||||||
}
|
}
|
||||||
return { exitActive, exitText };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meant to be used as part of a button's onClick handler.
|
// Meant to be used as part of a button's onClick handler.
|
||||||
// For convenience, you can pass a falsy event and it will be ignored.
|
// For convenience, you can pass a falsy event and it will be ignored.
|
||||||
const logClick = (org, courseId, administrator, event, extraProperties) => {
|
const logClick = (org, courseId, administrator, event) => {
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,7 +81,6 @@ const logClick = (org, courseId, administrator, event, extraProperties) => {
|
|||||||
org_key: org,
|
org_key: org,
|
||||||
courserun_key: courseId,
|
courserun_key: courseId,
|
||||||
is_staff: administrator,
|
is_staff: administrator,
|
||||||
...extraProperties,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +103,7 @@ const logVisit = (org, courseId, administrator, variant) => {
|
|||||||
export {
|
export {
|
||||||
COURSE_EXIT_MODES,
|
COURSE_EXIT_MODES,
|
||||||
getCourseExitMode,
|
getCourseExitMode,
|
||||||
getCourseExitNavigation,
|
getCourseExitText,
|
||||||
logClick,
|
logClick,
|
||||||
logVisit,
|
logVisit,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { Button, Form } from '@edx/paragon';
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faPlug } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import PluginComponent from '../../../plugin-test/PluginComponent';
|
|
||||||
import { useModel, useModels } from '../../../generic/model-store';
|
|
||||||
import { loadDynamicScript, loadScriptComponent } from '../../../plugin-test/utils';
|
|
||||||
|
|
||||||
export default function InContextSidebar({
|
|
||||||
courseId, sectionId, unitId, sequenceId,
|
|
||||||
}) {
|
|
||||||
const course = useModel('coursewareMeta', courseId);
|
|
||||||
const section = useModel('sections', sectionId);
|
|
||||||
const unit = useModel('units', unitId);
|
|
||||||
const sequence = useModel('sequences', sequenceId);
|
|
||||||
|
|
||||||
const sections = useModels('sections', course ? course.sectionIds : []);
|
|
||||||
let sequenceIds = [];
|
|
||||||
if (sections) {
|
|
||||||
sections.forEach((curSection) => {
|
|
||||||
sequenceIds = sequenceIds.concat(curSection.sequenceIds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const sequences = useModels('sequences', sequenceIds);
|
|
||||||
let unitIds = [];
|
|
||||||
if (sequences) {
|
|
||||||
sequences.forEach((curSequence) => {
|
|
||||||
unitIds = unitIds.concat(curSequence.unitIds);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const units = useModels('units', unitIds);
|
|
||||||
|
|
||||||
const [pluginUrl, setPluginUrl] = useState('');
|
|
||||||
const handlePluginUrlChange = (event) => {
|
|
||||||
setPluginUrl(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePluginAdd = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
loadDynamicScript(pluginUrl).then(() => {
|
|
||||||
const pluginFunction = loadScriptComponent('plugin', './Pomodoro');
|
|
||||||
pluginFunction();
|
|
||||||
setPluginUrl('');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!course || !section || !unit || !sequence) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="pt-3">
|
|
||||||
<div className="mb-3">
|
|
||||||
<Form onSubmit={handlePluginAdd}>
|
|
||||||
<h4><FontAwesomeIcon icon={faPlug} /> Add a plugin?</h4>
|
|
||||||
<Form.Control className="mb-1" onChange={handlePluginUrlChange} placeholder="URL to your plugin..." type="text" />
|
|
||||||
<Button type="submit">Add</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
{getConfig().plugins.slots.coursewareSidebar.map((plugin) => (
|
|
||||||
<React.Fragment key={`plugin-${plugin.url}-${plugin.module}`}>
|
|
||||||
<PluginComponent
|
|
||||||
course={course}
|
|
||||||
activeUnit={unit}
|
|
||||||
activeSequence={sequence}
|
|
||||||
activeSection={section}
|
|
||||||
unitIds={unitIds}
|
|
||||||
units={units}
|
|
||||||
sequences={sequences}
|
|
||||||
sections={sections}
|
|
||||||
plugin={plugin}
|
|
||||||
/>
|
|
||||||
<hr />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InContextSidebar.propTypes = {
|
|
||||||
courseId: PropTypes.string,
|
|
||||||
sectionId: PropTypes.string,
|
|
||||||
unitId: PropTypes.string,
|
|
||||||
sequenceId: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
InContextSidebar.defaultProps = {
|
|
||||||
courseId: null,
|
|
||||||
sectionId: null,
|
|
||||||
unitId: null,
|
|
||||||
sequenceId: null,
|
|
||||||
};
|
|
||||||
@@ -11,11 +11,6 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { history } from '@edx/frontend-platform';
|
import { history } from '@edx/frontend-platform';
|
||||||
|
|
||||||
// These should be reverted after the REV1512 experiment
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
import { Col, Container, Row } from '@edx/paragon';
|
|
||||||
import PageLoading from '../../../generic/PageLoading';
|
import PageLoading from '../../../generic/PageLoading';
|
||||||
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
@@ -24,168 +19,9 @@ import CourseLicense from '../course-license';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||||
import SequenceContent from './SequenceContent';
|
import SequenceContent from './SequenceContent';
|
||||||
import InContextSidebar from './InContextSidebar';
|
|
||||||
|
|
||||||
function REV1512Flyover({ toggleREV1512Flyover }) {
|
|
||||||
// This component should be reverted after the REV1512 experiment
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rev-1512-box"
|
|
||||||
style={{
|
|
||||||
border: 'solid 1px #e1dddb',
|
|
||||||
height: '393px',
|
|
||||||
width: '330px',
|
|
||||||
verticalAlign: 'top',
|
|
||||||
marginLeft: '20px',
|
|
||||||
padding: '0 20px 20px 20px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rev-1512-notification-div"
|
|
||||||
style={{
|
|
||||||
margin: '0 -20px 15px',
|
|
||||||
padding: '9px 20px 0',
|
|
||||||
fontSize: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="rev-1512-notification-span">Notifications</span>
|
|
||||||
<span
|
|
||||||
onClick={() => {
|
|
||||||
toggleREV1512Flyover();
|
|
||||||
}}
|
|
||||||
className="hideFlyover"
|
|
||||||
onKeyPress={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
toggleREV1512Flyover();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 14 14"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
style={{
|
|
||||||
float: 'right',
|
|
||||||
marginTop: '5.5px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<path d="M9.60625 7L13.5152 3.09102C13.9949 2.61133 13.9949 1.83359 13.5152 1.35352L12.6465 0.484766C12.1668 0.00507814 11.3891 0.00507814 10.909 0.484766L7 4.39375L3.09102 0.484766C2.61133 0.00507814 1.83359 0.00507814 1.35352 0.484766L0.484766 1.35352C0.00507814 1.8332 0.00507814 2.61094 0.484766 3.09102L4.39375 7L0.484766 10.909C0.00507814 11.3887 0.00507814 12.1664 0.484766 12.6465L1.35352 13.5152C1.8332 13.9949 2.61133 13.9949 3.09102 13.5152L7 9.60625L10.909 13.5152C11.3887 13.9949 12.1668 13.9949 12.6465 13.5152L13.5152 12.6465C13.9949 12.1668 13.9949 11.3891 13.5152 10.909L9.60625 7Z" fill="black" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="rev-1512-notification-block"
|
|
||||||
style={{
|
|
||||||
height: '9px',
|
|
||||||
background: '#F9F9F9',
|
|
||||||
margin: '7px -20px 0',
|
|
||||||
borderTop: '1px solid rgb(225, 221, 219)',
|
|
||||||
borderBottom: '1px solid rgb(225, 221, 219)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="rev-1512-optimizely-flyover-container" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
REV1512Flyover.propTypes = {
|
|
||||||
toggleREV1512Flyover: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function REV1512FlyoverMobile({ toggleREV1512Flyover }) {
|
|
||||||
// This component should be reverted after the REV1512 experiment
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rev-1512-box"
|
|
||||||
style={{
|
|
||||||
verticalAlign: 'top',
|
|
||||||
padding: '0 20px 20px 20px',
|
|
||||||
position: 'fixed',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
zIndex: 1,
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rev-1512-mobile-return-div"
|
|
||||||
style={{
|
|
||||||
margin: '0 -20px',
|
|
||||||
padding: '9px 20px 15px',
|
|
||||||
fontSize: '16px',
|
|
||||||
borderBottom: '1px solid rgb(225, 221, 219)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="rev-1512-mobile-return-span"
|
|
||||||
onClick={toggleREV1512Flyover}
|
|
||||||
onKeyPress={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
toggleREV1512Flyover();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
style={{
|
|
||||||
color: '#00262B',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faChevronLeft}
|
|
||||||
className="mr-2 fa-lg"
|
|
||||||
style={{
|
|
||||||
marginBottom: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="rev-1512-mobile-return-text">
|
|
||||||
Back to course
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="rev-1512-notification-div"
|
|
||||||
style={{
|
|
||||||
margin: '0 -20px 15px',
|
|
||||||
padding: '9px 20px 0',
|
|
||||||
fontSize: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="rev-1512-notification-span"
|
|
||||||
style={{
|
|
||||||
color: '#00262B',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Notifications
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="rev-1512-notification-block"
|
|
||||||
style={{
|
|
||||||
height: '9px',
|
|
||||||
background: '#F9F9F9',
|
|
||||||
margin: '7px -20px 0',
|
|
||||||
borderTop: '1px solid rgb(225, 221, 219)',
|
|
||||||
borderBottom: '1px solid rgb(225, 221, 219)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="rev-1512-optimizely-flyover-container" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
REV1512FlyoverMobile.propTypes = {
|
|
||||||
toggleREV1512Flyover: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function Sequence({
|
function Sequence({
|
||||||
unitId,
|
unitId,
|
||||||
sectionId,
|
|
||||||
sequenceId,
|
sequenceId,
|
||||||
courseId,
|
courseId,
|
||||||
unitNavigationHandler,
|
unitNavigationHandler,
|
||||||
@@ -194,9 +30,8 @@ function Sequence({
|
|||||||
intl,
|
intl,
|
||||||
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
|
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
|
||||||
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
||||||
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
}) {
|
}) {
|
||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('courses', courseId);
|
||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const unit = useModel('units', unitId);
|
const unit = useModel('units', unitId);
|
||||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||||
@@ -210,12 +45,6 @@ function Sequence({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// These should be reverted after the REV1512 experiment:
|
|
||||||
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
|
|
||||||
const isMobile = Boolean(
|
|
||||||
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
const handlePrevious = () => {
|
||||||
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
|
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
|
||||||
if (previousIndex >= 0) {
|
if (previousIndex >= 0) {
|
||||||
@@ -312,19 +141,12 @@ function Sequence({
|
|||||||
if (sequenceStatus === 'loaded') {
|
if (sequenceStatus === 'loaded') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Container className="sequence-container px-3">
|
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
||||||
<Row>
|
<div className="sequence" style={{ width: '100%' }}>
|
||||||
<Col sm={8} xs={12} className="sequence px-0">
|
|
||||||
<SequenceNavigation
|
<SequenceNavigation
|
||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/* This line should be reverted after REV1512 experiment */
|
|
||||||
toggleREV1512Flyover={toggleREV1512Flyover}
|
|
||||||
/* This line should be reverted after REV1512 experiment */
|
|
||||||
REV1512FlyoverEnabled={REV1512FlyoverEnabled}
|
|
||||||
/* should be reverted after REV1512 experiment */
|
|
||||||
isREV1512FlyoverVisible={isREV1512FlyoverVisible}
|
|
||||||
nextSequenceHandler={() => {
|
nextSequenceHandler={() => {
|
||||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||||
handleNext();
|
handleNext();
|
||||||
@@ -363,23 +185,22 @@ function Sequence({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</div>
|
||||||
<Col className="ml-3 sequence">
|
|
||||||
<InContextSidebar
|
|
||||||
courseId={courseId}
|
|
||||||
sectionId={sectionId}
|
|
||||||
sequenceId={sequenceId}
|
|
||||||
unitId={unitId}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
{/* This block of code should be reverted post REV1512 experiment */}
|
{/* This block of code should be reverted post REV1512 experiment */}
|
||||||
{REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
|
{REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
|
||||||
isMobile
|
<div
|
||||||
? <REV1512FlyoverMobile toggleREV1512Flyover={toggleREV1512Flyover} />
|
className="rev-1512-box"
|
||||||
: <REV1512Flyover toggleREV1512Flyover={toggleREV1512Flyover} />
|
style={{
|
||||||
|
border: 'solid 1px #e1dddb',
|
||||||
|
height: '393px',
|
||||||
|
width: '330px',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
marginLeft: '20px',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</div>
|
||||||
</Container>
|
|
||||||
<CourseLicense license={course.license || undefined} />
|
<CourseLicense license={course.license || undefined} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -396,20 +217,17 @@ function Sequence({
|
|||||||
Sequence.propTypes = {
|
Sequence.propTypes = {
|
||||||
unitId: PropTypes.string,
|
unitId: PropTypes.string,
|
||||||
sequenceId: PropTypes.string,
|
sequenceId: PropTypes.string,
|
||||||
sectionId: PropTypes.string,
|
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
unitNavigationHandler: PropTypes.func.isRequired,
|
unitNavigationHandler: PropTypes.func.isRequired,
|
||||||
nextSequenceHandler: PropTypes.func.isRequired,
|
nextSequenceHandler: PropTypes.func.isRequired,
|
||||||
previousSequenceHandler: PropTypes.func.isRequired,
|
previousSequenceHandler: PropTypes.func.isRequired,
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||||
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
||||||
};
|
};
|
||||||
|
|
||||||
Sequence.defaultProps = {
|
Sequence.defaultProps = {
|
||||||
sequenceId: null,
|
sequenceId: null,
|
||||||
sectionId: null,
|
|
||||||
unitId: null,
|
unitId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, {
|
import React, {
|
||||||
Suspense,
|
Suspense,
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -10,7 +9,6 @@ import { useDispatch } from 'react-redux';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
|
||||||
import { Modal } from '@edx/paragon';
|
import { Modal } from '@edx/paragon';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||||
@@ -20,7 +18,6 @@ import { processEvent } from '../../../course-home/data/thunks';
|
|||||||
import { fetchCourse } from '../../data/thunks';
|
import { fetchCourse } from '../../data/thunks';
|
||||||
|
|
||||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||||
const LockPaywallValuePropExperiment = React.lazy(() => import('./lock-paywall-value-prop'));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||||
@@ -61,9 +58,7 @@ function Unit({
|
|||||||
id,
|
id,
|
||||||
intl,
|
intl,
|
||||||
}) {
|
}) {
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`;
|
||||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
|
||||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
|
||||||
if (format) {
|
if (format) {
|
||||||
iframeUrl += `&format=${format}`;
|
iframeUrl += `&format=${format}`;
|
||||||
}
|
}
|
||||||
@@ -71,17 +66,9 @@ function Unit({
|
|||||||
const [iframeHeight, setIframeHeight] = useState(0);
|
const [iframeHeight, setIframeHeight] = useState(0);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||||
const [rev1512ValuePropExperimentLock, setRev1512ValuePropExperimentLock] = useState(
|
|
||||||
window.rev1512ValuePropExperimentLock,
|
|
||||||
);
|
|
||||||
/* TODO: The code block below + code referencing it should be deleted after REV1512 value prop experiment */
|
|
||||||
window.rev1512ToggleValuePropPaywallLock = () => {
|
|
||||||
window.rev1512ValuePropExperimentLock = !rev1512ValuePropExperimentLock;
|
|
||||||
setRev1512ValuePropExperimentLock(!rev1512ValuePropExperimentLock);
|
|
||||||
};
|
|
||||||
|
|
||||||
const unit = useModel('units', id);
|
const unit = useModel('units', id);
|
||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
contentTypeGatingEnabled,
|
contentTypeGatingEnabled,
|
||||||
} = course;
|
} = course;
|
||||||
@@ -130,7 +117,7 @@ function Unit({
|
|||||||
isBookmarked={unit.bookmarked}
|
isBookmarked={unit.bookmarked}
|
||||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||||
/>
|
/>
|
||||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
{ contentTypeGatingEnabled && unit.graded && (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={(
|
fallback={(
|
||||||
<PageLoading
|
<PageLoading
|
||||||
@@ -138,9 +125,9 @@ function Unit({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(rev1512ValuePropExperimentLock)
|
<LockPaywall
|
||||||
? <LockPaywallValuePropExperiment courseId={courseId} />
|
courseId={courseId}
|
||||||
: <LockPaywall courseId={courseId} />}
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{!hasLoaded && (
|
{!hasLoaded && (
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ describe('Unit', () => {
|
|||||||
{ courseId: courseMetadata.id },
|
{ courseId: courseMetadata.id },
|
||||||
), Factory.build(
|
), Factory.build(
|
||||||
'block',
|
'block',
|
||||||
{ type: 'vertical', contains_content_type_gated_content: true, bookmarked: true },
|
{ type: 'vertical', graded: true, bookmarked: true },
|
||||||
{ courseId: courseMetadata.id },
|
{ courseId: courseMetadata.id },
|
||||||
)];
|
)];
|
||||||
const [unit, unitThatContainsGatedContent] = unitBlocks;
|
const [unit, gradedUnit] = unitBlocks;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initializeTestStore({ courseMetadata, unitBlocks });
|
await initializeTestStore({ courseMetadata, unitBlocks });
|
||||||
@@ -38,12 +38,12 @@ describe('Unit', () => {
|
|||||||
const renderedUnit = screen.getByTitle(unit.display_name);
|
const renderedUnit = screen.getByTitle(unit.display_name);
|
||||||
expect(renderedUnit).toHaveAttribute('height', String(0));
|
expect(renderedUnit).toHaveAttribute('height', String(0));
|
||||||
expect(renderedUnit).toHaveAttribute(
|
expect(renderedUnit).toHaveAttribute(
|
||||||
'src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`,
|
'src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&format=${mockData.format}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders proper message for gated content', () => {
|
it('renders proper message for gated content', () => {
|
||||||
render(<Unit {...mockData} id={unitThatContainsGatedContent.id} />);
|
render(<Unit {...mockData} id={gradedUnit.id} />);
|
||||||
|
|
||||||
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
|
expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
/* TODO: This file should be deleted after REV1512 value prop experiment */
|
|
||||||
import React from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import {
|
|
||||||
injectIntl, getLocale,
|
|
||||||
} from '@edx/frontend-platform/i18n';
|
|
||||||
import { Button } from '@edx/paragon';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import VerifiedCert from '../../../../generic/assets/edX_certificate.png';
|
|
||||||
import { useModel } from '../../../../generic/model-store';
|
|
||||||
import './LockPaywall.scss';
|
|
||||||
|
|
||||||
function LockPaywall({
|
|
||||||
courseId,
|
|
||||||
}) {
|
|
||||||
const course = useModel('coursewareMeta', courseId);
|
|
||||||
const {
|
|
||||||
verifiedMode,
|
|
||||||
} = course;
|
|
||||||
if (!verifiedMode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
currencySymbol,
|
|
||||||
price,
|
|
||||||
upgradeUrl,
|
|
||||||
} = verifiedMode;
|
|
||||||
|
|
||||||
const isSpanish = getLocale() === 'es-419';
|
|
||||||
|
|
||||||
let upgradeButtonText;
|
|
||||||
|
|
||||||
if (document.querySelector('.price.discount') !== null) {
|
|
||||||
let discountPrice = document.querySelector('.price.discount').textContent;
|
|
||||||
if (discountPrice !== null) {
|
|
||||||
discountPrice = discountPrice.replace(/[^0-9.]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSpanish) {
|
|
||||||
upgradeButtonText = (
|
|
||||||
<>
|
|
||||||
<span className="font-weight-bold" style={{ paddingRight: '5px' }}>
|
|
||||||
Cómpralo por {currencySymbol}{discountPrice}
|
|
||||||
</span>
|
|
||||||
<span style={{ textDecoration: 'line-through' }}>
|
|
||||||
({currencySymbol}{price})
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
upgradeButtonText = (
|
|
||||||
<>
|
|
||||||
<span className="font-weight-bold" style={{ paddingRight: '5px' }}>
|
|
||||||
Upgrade for {currencySymbol}{discountPrice}
|
|
||||||
</span>
|
|
||||||
<span style={{ textDecoration: 'line-through' }}>
|
|
||||||
({currencySymbol}{price})
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (isSpanish) {
|
|
||||||
upgradeButtonText = (
|
|
||||||
<>
|
|
||||||
<span className="font-weight-bold" style={{ paddingRight: '5px' }}>
|
|
||||||
Cómpralo por {currencySymbol}{price}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
upgradeButtonText = (
|
|
||||||
<>
|
|
||||||
<span className="font-weight-bold" style={{ paddingRight: '5px' }}>
|
|
||||||
Upgrade for {currencySymbol}{price}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const circleCheckIcon = (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faCheckCircle}
|
|
||||||
className="float-left mt-1"
|
|
||||||
fixedWidth
|
|
||||||
aria-hidden="true"
|
|
||||||
title="icon"
|
|
||||||
style={{ marginRight: '15px' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const verifiedCertificateLink = (
|
|
||||||
<b>
|
|
||||||
<a
|
|
||||||
className="value-prop-verified-certificate-link"
|
|
||||||
style={{ textDecoration: 'underline', color: '#00688D' }}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
href="https://www.edx.org/verified-certificate"
|
|
||||||
>
|
|
||||||
{ (isSpanish) ? 'certificado verificado' : 'verified certificate' }
|
|
||||||
</a>
|
|
||||||
</b>
|
|
||||||
);
|
|
||||||
|
|
||||||
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
|
|
||||||
const isMobile = Boolean(
|
|
||||||
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-gray d-flex justify-content-between mt-2 p-3 value-prop-lock-paywall-banner">
|
|
||||||
<div className={classNames({ 'is-mobile': isMobile })}>
|
|
||||||
<div className="font-weight-bold top-banner-text-header">
|
|
||||||
<FontAwesomeIcon icon={faLock} className="text-black mr-2 ml-1 lock-icon" style={{ fontSize: '1rem' }} />
|
|
||||||
<span className="top-banner-title">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'Las tareas calificadas están bloqueadas'
|
|
||||||
: 'Graded assignments are locked'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="font-weight top-banner-text">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'Cámbiate a la opción verificada para obtener acceso a funciones bloqueadas como esta y aprovechar al máximo tu curso.'
|
|
||||||
: 'Upgrade to gain access to locked features like this one and get the most out of your course.'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={classNames('mb-0', 'cert-list-wrapper', { 'd-flex': !isMobile })}>
|
|
||||||
|
|
||||||
<div className="certificate-image-banner-container">
|
|
||||||
<img
|
|
||||||
alt="Example Certificate"
|
|
||||||
src={VerifiedCert}
|
|
||||||
className="border-0 certificate-image-banner"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="list-container" style={{ float: 'left', paddingLeft: '18px', paddingBottom: '24px' }}>
|
|
||||||
<div style={{ paddingBottom: '5px' }}>
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'Cuando te cambias a la opción verificada, tú:'
|
|
||||||
: 'When you upgrade, you:'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-item-row">
|
|
||||||
<div className="check-circle-icon-wrapper">{circleCheckIcon}</div>
|
|
||||||
<div className="list-item-wrapper">
|
|
||||||
<span>
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? <>Obtén un {verifiedCertificateLink} de finalización para compartirlo en tu currículum</>
|
|
||||||
: <>Earn a {verifiedCertificateLink} of completion to showcase on your resume</>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-item-row">
|
|
||||||
<div className="check-circle-icon-wrapper">{circleCheckIcon}</div>
|
|
||||||
<div className="list-item-wrapper">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'Desbloquea el acceso a todas las actividades del curso, incluidas las '
|
|
||||||
: 'Unlock access to all course activities, including '
|
|
||||||
}
|
|
||||||
<span className="font-weight-bold">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'tareas calificadas'
|
|
||||||
: 'graded assignments'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-item-row">
|
|
||||||
<div className="check-circle-icon-wrapper">{circleCheckIcon}</div>
|
|
||||||
<div className="list-item-wrapper">
|
|
||||||
<span className="font-weight-bold">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'Acceso completo'
|
|
||||||
: 'Gain full access'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? ' al contenido y los materiales del curso, incluso después de que finalice el curso'
|
|
||||||
: ' to course content and materials, even after the course ends'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-item-row">
|
|
||||||
<div className="check-circle-icon-wrapper">{circleCheckIcon}</div>
|
|
||||||
<div className="list-item-wrapper">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'Apoya nuestra '
|
|
||||||
: 'Support our '
|
|
||||||
}
|
|
||||||
<span className="font-weight-bold">
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? 'misión sin fines de lucro'
|
|
||||||
: 'non-profit mission'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
{
|
|
||||||
isSpanish
|
|
||||||
? ' en edX'
|
|
||||||
: ' at edX'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="value-prop-upgrade-button-container">
|
|
||||||
<Button variant="primary" href={upgradeUrl} className="value-prop-lock-paywall-upgrade-link">
|
|
||||||
{upgradeButtonText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
LockPaywall.propTypes = {
|
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
export default injectIntl(LockPaywall);
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/* TODO: This file should be deleted after REV1512 value prop experiment */
|
|
||||||
.value-prop-lock-paywall-banner {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
|
|
||||||
.list-item-row {
|
|
||||||
display: flex;
|
|
||||||
margin-left: -7px;
|
|
||||||
line-height: 1.5rem;
|
|
||||||
padding-left: 5px;
|
|
||||||
}
|
|
||||||
.certificate-image-banner {
|
|
||||||
width: 172px;
|
|
||||||
height: 123px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.certificate-image-banner-container {
|
|
||||||
display: inline-block;
|
|
||||||
float: left;
|
|
||||||
padding-left: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.top-banner-text {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
.top-banner-text-header {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
.value-prop-upgrade-button-container {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
.is-mobile {
|
|
||||||
padding-top: 10px;
|
|
||||||
|
|
||||||
.certificate-image-banner-container {
|
|
||||||
text-align: center;
|
|
||||||
display: block;
|
|
||||||
float: none;
|
|
||||||
padding-left: 0px;
|
|
||||||
}
|
|
||||||
.certificate-image-banner {
|
|
||||||
width: 180px;
|
|
||||||
height: 128px;
|
|
||||||
margin-top: 3px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.top-banner-text-header {
|
|
||||||
margin-left:36px;
|
|
||||||
position: relative;
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
.top-banner-text {
|
|
||||||
margin-left:36px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
position: relative;
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
.lock-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: -28px;
|
|
||||||
top: 4px;
|
|
||||||
}
|
|
||||||
.value-prop-upgrade-button-container {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
padding-top: 20px;
|
|
||||||
padding-left: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 576px) {
|
|
||||||
.top-banner-text-header {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
.top-banner-text {
|
|
||||||
padding-left: 47px !important;
|
|
||||||
}
|
|
||||||
.cert-list-wrapper {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
.certificate-image-banner-container {
|
|
||||||
width: 100%;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
padding-left: 0px !important;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.value-prop-upgrade-button-container {
|
|
||||||
padding-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
padding-left: 0px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/* TODO: This file should be deleted after REV1512 value prop experiment */
|
|
||||||
import React from 'react';
|
|
||||||
import { Factory } from 'rosie';
|
|
||||||
import { initializeTestStore, render, screen } from '../../../../setupTest';
|
|
||||||
import LockPaywall from './LockPaywall';
|
|
||||||
|
|
||||||
describe('Lock Paywall', () => {
|
|
||||||
let store;
|
|
||||||
const mockData = {};
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
store = await initializeTestStore();
|
|
||||||
const { courseware } = store.getState();
|
|
||||||
mockData.courseId = courseware.courseId;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays message along with lock icon', () => {
|
|
||||||
const { container } = render(<LockPaywall {...mockData} />);
|
|
||||||
|
|
||||||
const lockIcon = container.querySelector('svg');
|
|
||||||
expect(lockIcon).toHaveClass('fa-lock');
|
|
||||||
expect(lockIcon.parentElement).toHaveTextContent('Graded assignments are locked');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays unlock link with price', () => {
|
|
||||||
const {
|
|
||||||
currencySymbol,
|
|
||||||
price,
|
|
||||||
upgradeUrl,
|
|
||||||
} = store.getState().models.coursewareMeta[mockData.courseId].verifiedMode;
|
|
||||||
render(<LockPaywall {...mockData} />);
|
|
||||||
|
|
||||||
const upgradeLink = screen.getByRole('link', { name: `Upgrade for ${currencySymbol}${price}` });
|
|
||||||
expect(upgradeLink).toHaveAttribute('href', `${upgradeUrl}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not display anything if course does not have verified mode', async () => {
|
|
||||||
const courseMetadata = Factory.build('courseMetadata', { verified_mode: null });
|
|
||||||
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
|
|
||||||
const { container } = render(<LockPaywall {...mockData} courseId={courseMetadata.id} />, { store: testStore });
|
|
||||||
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/* TODO: This file + folder should be deleted after REV1512 value prop experiment */
|
|
||||||
export { default } from './LockPaywall';
|
|
||||||
@@ -5,14 +5,14 @@ import { faLock } from '@fortawesome/free-solid-svg-icons';
|
|||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import VerifiedCert from '../../../../generic/assets/edX_certificate.png';
|
import VerifiedCert from '../../../../generic/assets/edX_verified_certificate.png';
|
||||||
import { useModel } from '../../../../generic/model-store';
|
import { useModel } from '../../../../generic/model-store';
|
||||||
|
|
||||||
function LockPaywall({
|
function LockPaywall({
|
||||||
intl,
|
intl,
|
||||||
courseId,
|
courseId,
|
||||||
}) {
|
}) {
|
||||||
const course = useModel('coursewareMeta', courseId);
|
const course = useModel('courses', courseId);
|
||||||
const {
|
const {
|
||||||
verifiedMode,
|
verifiedMode,
|
||||||
} = course;
|
} = course;
|
||||||
@@ -35,7 +35,7 @@ function LockPaywall({
|
|||||||
<p className="mb-0">
|
<p className="mb-0">
|
||||||
<span>{intl.formatMessage(messages['learn.lockPaywall.content'])}</span>
|
<span>{intl.formatMessage(messages['learn.lockPaywall.content'])}</span>
|
||||||
|
|
||||||
<a className="lock_paywall_upgrade_link" href={upgradeUrl}>
|
<a href={upgradeUrl}>
|
||||||
{intl.formatMessage(messages['learn.lockPaywall.upgrade.link'], {
|
{intl.formatMessage(messages['learn.lockPaywall.upgrade.link'], {
|
||||||
currencySymbol,
|
currencySymbol,
|
||||||
price,
|
price,
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ describe('Lock Paywall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays unlock link with price', () => {
|
it('displays unlock link with price', () => {
|
||||||
const {
|
const { currencySymbol, price, upgradeUrl } = store.getState().models.courses[mockData.courseId].verifiedMode;
|
||||||
currencySymbol,
|
|
||||||
price,
|
|
||||||
upgradeUrl,
|
|
||||||
} = store.getState().models.coursewareMeta[mockData.courseId].verifiedMode;
|
|
||||||
render(<LockPaywall {...mockData} />);
|
render(<LockPaywall {...mockData} />);
|
||||||
|
|
||||||
const upgradeLink = screen.getByRole('link', { name: `Upgrade to unlock (${currencySymbol}${price})` });
|
const upgradeLink = screen.getByRole('link', { name: `Upgrade to unlock (${currencySymbol}${price})` });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons
|
|||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { getCourseExitNavigation } from '../../course-exit';
|
import { getCourseExitText } from '../../course-exit';
|
||||||
import UnitButton from './UnitButton';
|
import UnitButton from './UnitButton';
|
||||||
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||||
import { useSequenceNavigationMetadata } from './hooks';
|
import { useSequenceNavigationMetadata } from './hooks';
|
||||||
@@ -25,9 +25,6 @@ function SequenceNavigation({
|
|||||||
nextSequenceHandler,
|
nextSequenceHandler,
|
||||||
previousSequenceHandler,
|
previousSequenceHandler,
|
||||||
goToCourseExitPage,
|
goToCourseExitPage,
|
||||||
toggleREV1512Flyover, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
REV1512FlyoverEnabled, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
isREV1512FlyoverVisible, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
}) {
|
}) {
|
||||||
const sequence = useModel('sequences', sequenceId);
|
const sequence = useModel('sequences', sequenceId);
|
||||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||||
@@ -61,10 +58,10 @@ function SequenceNavigation({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderNextButton = () => {
|
const renderNextButton = () => {
|
||||||
const { exitActive, exitText } = getCourseExitNavigation(courseId, intl);
|
const exitText = getCourseExitText(courseId, intl);
|
||||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
|
const buttonOnClick = isLastUnit ? goToCourseExitPage : nextSequenceHandler;
|
||||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||||
const disabled = isLastUnit && !exitActive;
|
const disabled = isLastUnit && !exitText;
|
||||||
return (
|
return (
|
||||||
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled}>
|
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled}>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
@@ -73,12 +70,6 @@ function SequenceNavigation({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// These should be reverted after the REV1512 experiment
|
|
||||||
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
|
|
||||||
const isMobile = Boolean(
|
|
||||||
userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i),
|
|
||||||
);
|
|
||||||
|
|
||||||
return sequenceStatus === LOADED && (
|
return sequenceStatus === LOADED && (
|
||||||
<nav className={classNames('sequence-navigation', className)}>
|
<nav className={classNames('sequence-navigation', className)}>
|
||||||
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
||||||
@@ -87,22 +78,6 @@ function SequenceNavigation({
|
|||||||
</Button>
|
</Button>
|
||||||
{renderUnitButtons()}
|
{renderUnitButtons()}
|
||||||
{renderNextButton()}
|
{renderNextButton()}
|
||||||
{REV1512FlyoverEnabled
|
|
||||||
&& isMobile && (
|
|
||||||
<div
|
|
||||||
className="toggleFlyoverButton toggleFlyoverButtonMobile"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ borderBottom: isREV1512FlyoverVisible() ? '2px solid #00262b' : 'none' }}
|
|
||||||
onClick={() => {
|
|
||||||
toggleREV1512Flyover();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="54" height="40" viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="0.5" y="0.5" width="53" height="39" rx="1.5" fill="white" stroke="#E7E8E9" /><path d="M36 20C36 15.6 32.4 12 28 12C27.7 12 27.5 12.2 27.5 12.5C27.5 12.8 27.7 13 28 13C31.85 13 35 16.15 35 20C35 23.85 31.85 27 28 27C24.15 27 21 23.85 21 20C21 19.7 20.8 19.5 20.5 19.5C20.3 19.5 20.1 19.65 20.05 19.8C20 19.85 20 19.95 20 20C20 24.4 23.6 28 28 28C32.4 28 36 24.4 36 20Z" fill="black" stroke="black" strokeWidth="0.6" /><path d="M23.1065 14.52C22.9403 14.36 22.691 14.36 22.5247 14.52C22.3585 14.68 22.3585 14.92 22.5247 15.08C22.6078 15.16 22.7325 15.2 22.8156 15.2C22.9403 15.2 23.0234 15.16 23.1065 15.08C23.2312 14.96 23.2312 14.68 23.1065 14.52Z" fill="black" stroke="black" strokeWidth="0.6" /><path d="M27.6848 15.2C27.3939 15.2 27.2 15.3973 27.2 15.6932V19.6384C27.2 19.6877 27.2 19.7863 27.2484 19.8356C27.2969 19.8849 27.2969 19.9343 27.3454 19.9836L29.5757 22.2521C29.6727 22.3507 29.8181 22.4 29.9151 22.4C30.0121 22.4 30.1575 22.3507 30.2545 22.2521C30.4484 22.0548 30.4484 21.7589 30.2545 21.5617L28.1696 19.4411V15.6932C28.1696 15.3973 27.9757 15.2 27.6848 15.2Z" fill="black" stroke="black" strokeWidth="0.6" /><circle cx="35.5" cy="14.5" r="4.5" fill="#C32D3A" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="rev1512ToggleFlyoverSequenceLocation" />
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,9 +91,6 @@ SequenceNavigation.propTypes = {
|
|||||||
nextSequenceHandler: PropTypes.func.isRequired,
|
nextSequenceHandler: PropTypes.func.isRequired,
|
||||||
previousSequenceHandler: PropTypes.func.isRequired,
|
previousSequenceHandler: PropTypes.func.isRequired,
|
||||||
goToCourseExitPage: PropTypes.func.isRequired,
|
goToCourseExitPage: PropTypes.func.isRequired,
|
||||||
toggleREV1512Flyover: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
REV1512FlyoverEnabled: PropTypes.bool.isRequired, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
isREV1512FlyoverVisible: PropTypes.func.isRequired, /* This line should be reverted after the REV1512 experiment */
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SequenceNavigation.defaultProps = {
|
SequenceNavigation.defaultProps = {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe('Sequence Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays end of course message instead of the "Next" button as needed', async () => {
|
it('displays end of course message instead of the "Next" button as needed', async () => {
|
||||||
const testMetadata = { ...courseMetadata, certificate_data: { cert_status: 'notpassing' }, enrollment: { is_active: true } };
|
const testMetadata = { ...courseMetadata, certificate_data: { cert_status: 'notpassing' } };
|
||||||
const testStore = await initializeTestStore({ courseMetadata: testMetadata, unitBlocks }, false);
|
const testStore = await initializeTestStore({ courseMetadata: testMetadata, unitBlocks }, false);
|
||||||
// Have to refetch the sequenceId since the new store generates new sequences
|
// Have to refetch the sequenceId since the new store generates new sequences
|
||||||
const { courseware } = testStore.getState();
|
const { courseware } = testStore.getState();
|
||||||
@@ -134,7 +134,6 @@ describe('Sequence Navigation', () => {
|
|||||||
const testMetadata = {
|
const testMetadata = {
|
||||||
...courseMetadata,
|
...courseMetadata,
|
||||||
certificate_data: { cert_status: 'downloadable' },
|
certificate_data: { cert_status: 'downloadable' },
|
||||||
enrollment: { is_active: true },
|
|
||||||
user_has_passing_grade: true,
|
user_has_passing_grade: true,
|
||||||
};
|
};
|
||||||
const testStore = await initializeTestStore({ courseMetadata: testMetadata, unitBlocks }, false);
|
const testStore = await initializeTestStore({ courseMetadata: testMetadata, unitBlocks }, false);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons
|
|||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { getCourseExitNavigation } from '../../course-exit';
|
import { getCourseExitText } from '../../course-exit';
|
||||||
|
|
||||||
import { useSequenceNavigationMetadata } from './hooks';
|
import { useSequenceNavigationMetadata } from './hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -23,10 +23,10 @@ function UnitNavigation({
|
|||||||
const { courseId } = useSelector(state => state.courseware);
|
const { courseId } = useSelector(state => state.courseware);
|
||||||
|
|
||||||
const renderNextButton = () => {
|
const renderNextButton = () => {
|
||||||
const { exitActive, exitText } = getCourseExitNavigation(courseId, intl);
|
const exitText = getCourseExitText(courseId, intl);
|
||||||
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
|
const buttonOnClick = isLastUnit ? goToCourseExitPage : onClickNext;
|
||||||
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton);
|
||||||
const disabled = isLastUnit && !exitActive;
|
const disabled = isLastUnit && !exitText;
|
||||||
return (
|
return (
|
||||||
<Button variant="outline-primary" className="next-button" onClick={buttonOnClick} disabled={disabled}>
|
<Button variant="outline-primary" className="next-button" onClick={buttonOnClick} disabled={disabled}>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe('Unit Navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays end of course message instead of the "Next" button as needed', async () => {
|
it('displays end of course message instead of the "Next" button as needed', async () => {
|
||||||
const testCourseMetadata = { ...courseMetadata, certificate_data: { cert_status: 'notpassing' }, enrollment: { is_active: true } };
|
const testCourseMetadata = { ...courseMetadata, certificate_data: { cert_status: 'notpassing' } };
|
||||||
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
||||||
// Have to refetch the sequenceId since the new store generates new sequences
|
// Have to refetch the sequenceId since the new store generates new sequences
|
||||||
const { courseware } = testStore.getState();
|
const { courseware } = testStore.getState();
|
||||||
@@ -109,7 +109,6 @@ describe('Unit Navigation', () => {
|
|||||||
const testCourseMetadata = {
|
const testCourseMetadata = {
|
||||||
...courseMetadata,
|
...courseMetadata,
|
||||||
certificate_data: { cert_status: 'downloadable' },
|
certificate_data: { cert_status: 'downloadable' },
|
||||||
enrollment: { is_active: true },
|
|
||||||
user_has_passing_grade: true,
|
user_has_passing_grade: true,
|
||||||
};
|
};
|
||||||
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
const testStore = await initializeTestStore({ courseMetadata: testCourseMetadata, unitBlocks }, false);
|
||||||
|
|||||||
@@ -77,11 +77,7 @@ export default function buildSimpleCourseBlocks(courseId, title, options = {}) {
|
|||||||
return {
|
return {
|
||||||
courseBlocks: options.courseBlocks || Factory.build(
|
courseBlocks: options.courseBlocks || Factory.build(
|
||||||
'courseBlocks',
|
'courseBlocks',
|
||||||
{
|
{ courseId },
|
||||||
courseId,
|
|
||||||
hasScheduledContent: options.hasScheduledContent || false,
|
|
||||||
title: 'Demo Course',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
units: unitBlocks,
|
units: unitBlocks,
|
||||||
sequence: sequenceBlock,
|
sequence: sequenceBlock,
|
||||||
|
|||||||
@@ -116,16 +116,6 @@ Factory.define('courseMetadata')
|
|||||||
},
|
},
|
||||||
{ courseId: id, path: 'instructor' },
|
{ courseId: id, path: 'instructor' },
|
||||||
),
|
),
|
||||||
Factory.build(
|
|
||||||
'tab',
|
|
||||||
{
|
|
||||||
title: 'Dates',
|
|
||||||
priority: 5,
|
|
||||||
slug: 'dates',
|
|
||||||
type: 'dates',
|
|
||||||
},
|
|
||||||
{ courseId: id, path: 'dates' },
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return tabs;
|
return tabs;
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ Factory.define('sequenceMetadata')
|
|||||||
complete: unitBlock.complete || null,
|
complete: unitBlock.complete || null,
|
||||||
content: '',
|
content: '',
|
||||||
page_title: unitBlock.display_name,
|
page_title: unitBlock.display_name,
|
||||||
contains_content_type_gated_content: unitBlock.contains_content_type_gated_content,
|
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
.attrs({
|
.attrs({
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function normalizeBlocks(courseId, blocks) {
|
|||||||
id: courseId,
|
id: courseId,
|
||||||
title: block.display_name,
|
title: block.display_name,
|
||||||
sectionIds: block.children || [],
|
sectionIds: block.children || [],
|
||||||
hasScheduledContent: block.has_scheduled_content || false,
|
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case 'chapter':
|
case 'chapter':
|
||||||
@@ -87,12 +86,12 @@ export function normalizeBlocks(courseId, blocks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCourseBlocks(courseId) {
|
export async function getCourseBlocks(courseId) {
|
||||||
const authenticatedUser = getAuthenticatedUser();
|
const { username } = getAuthenticatedUser();
|
||||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
|
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`);
|
||||||
url.searchParams.append('course_id', courseId);
|
url.searchParams.append('course_id', courseId);
|
||||||
url.searchParams.append('username', authenticatedUser ? authenticatedUser.username : '');
|
url.searchParams.append('username', username);
|
||||||
url.searchParams.append('depth', 3);
|
url.searchParams.append('depth', 3);
|
||||||
url.searchParams.append('requested_fields', 'children,show_gated_sections,graded,special_exam_info,has_scheduled_content');
|
url.searchParams.append('requested_fields', 'children,show_gated_sections,graded,special_exam_info');
|
||||||
|
|
||||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||||
return normalizeBlocks(courseId, data.blocks);
|
return normalizeBlocks(courseId, data.blocks);
|
||||||
@@ -111,13 +110,14 @@ function normalizeTabUrls(id, tabs) {
|
|||||||
|
|
||||||
function normalizeMetadata(metadata) {
|
function normalizeMetadata(metadata) {
|
||||||
return {
|
return {
|
||||||
accessExpiration: camelCaseObject(metadata.access_expiration),
|
|
||||||
canShowUpgradeSock: metadata.can_show_upgrade_sock,
|
canShowUpgradeSock: metadata.can_show_upgrade_sock,
|
||||||
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
|
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
|
||||||
|
// TODO: TNL-7185: return course expired _date_, instead of _message_
|
||||||
|
courseExpiredMessage: metadata.course_expired_message,
|
||||||
id: metadata.id,
|
id: metadata.id,
|
||||||
title: metadata.name,
|
title: metadata.name,
|
||||||
number: metadata.number,
|
number: metadata.number,
|
||||||
offer: camelCaseObject(metadata.offer),
|
offerHtml: metadata.offer_html,
|
||||||
org: metadata.org,
|
org: metadata.org,
|
||||||
enrollmentStart: metadata.enrollment_start,
|
enrollmentStart: metadata.enrollment_start,
|
||||||
enrollmentEnd: metadata.enrollment_end,
|
enrollmentEnd: metadata.enrollment_end,
|
||||||
@@ -131,7 +131,6 @@ function normalizeMetadata(metadata) {
|
|||||||
license: metadata.license,
|
license: metadata.license,
|
||||||
verifiedMode: camelCaseObject(metadata.verified_mode),
|
verifiedMode: camelCaseObject(metadata.verified_mode),
|
||||||
tabs: normalizeTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
|
tabs: normalizeTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
|
||||||
userTimezone: metadata.user_timezone,
|
|
||||||
showCalculator: metadata.show_calculator,
|
showCalculator: metadata.show_calculator,
|
||||||
notes: camelCaseObject(metadata.notes),
|
notes: camelCaseObject(metadata.notes),
|
||||||
marketingUrl: metadata.marketing_url,
|
marketingUrl: metadata.marketing_url,
|
||||||
@@ -183,7 +182,6 @@ function normalizeSequenceMetadata(sequence) {
|
|||||||
complete: unit.complete,
|
complete: unit.complete,
|
||||||
title: unit.page_title,
|
title: unit.page_title,
|
||||||
contentType: unit.type,
|
contentType: unit.type,
|
||||||
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user