Compare commits

..

1 Commits

Author SHA1 Message Date
Dillon Dumesnil
75eaf6611d AGAIN, this is Dillon's work and not MT's. Anybody saying otherwise is a liar 2020-09-03 14:15:13 -04:00
187 changed files with 7087 additions and 12692 deletions

15
.env
View File

@@ -5,27 +5,16 @@ CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=null
ECOMMERCE_BASE_URL=null
INSIGHTS_BASE_URL=null
INSIGHTS_BASE_URL=
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
LOGO_URL=null
LOGO_TRADEMARK_URL=null
LOGO_WHITE_URL=null
FAVICON_URL=null
MARKETING_SITE_BASE_URL=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEARCH_CATALOG_URL=null
SEGMENT_KEY=null
SITE_NAME=null
SOCIAL_UTM_MILESTONE_CAMPAIGN=null
STUDIO_BASE_URL=null
SUPPORT_URL=null
SUPPORT_URL_CALCULATOR_MATH=null
SUPPORT_URL_ID_VERIFICATION=null
SUPPORT_URL_VERIFIED_CERTIFICATE=null
TWITTER_HASHTAG=null
TWITTER_URL=null
STUDIO_BASE_URL=
USER_INFO_COOKIE_NAME=null

View File

@@ -9,23 +9,12 @@ LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=null
SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -4,28 +4,16 @@ BASE_URL='http://localhost:2000'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=null
SITE_NAME='edX'
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
STUDIO_BASE_URL='http://localhost:18010'
SUPPORT_URL='https://support.edx.org'
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
STUDIO_BASE_URL='http://localhost:18010'
USER_INFO_COOKIE_NAME='edx-user-info'

View File

@@ -1,21 +0,0 @@
name: validate
on:
- push
- pull_request
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 12
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: make validate.ci
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: true

15
.travis.yml Executable file
View File

@@ -0,0 +1,15 @@
language: node_js
node_js: 12
before_install:
- npm install -g npm@6
install:
- npm ci
script:
- make validate-no-uncommitted-package-lock-changes
- npm run i18n_extract
- npm run lint -- --max-warnings 0
- npm run test
- npm run build
- npm run is-es5
after_success:
- codecov

View File

@@ -52,17 +52,3 @@ pull_translations:
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json
.PHONY: validate
validate:
make validate-no-uncommitted-package-lock-changes
npm run i18n_extract
npm run lint -- --max-warnings 0
npm run test
npm run build
npm run is-es5
.PHONY: validate.ci
validate.ci:
npm ci
make validate

View File

@@ -1,4 +1,4 @@
|Coveralls| |npm_version| |npm_downloads| |license|
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
frontend-app-learning
=========================
@@ -10,6 +10,8 @@ Introduction
React app for edX learning.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-learning
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-learning
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg

View File

@@ -1,66 +0,0 @@
# Testing
## Status
Draft
Let's live with this a bit longer before deciding it's a solid approach and marking this Approved.
## Context
We'd like to all be on the same page about how to approach testing, what is
worth testing, and how to do it.
## React Testing Library
We'll use react-testing-library and jest as the main testing tools.
This has some implications about how to test. You can read the React Testing Library's
[Guiding Principles](https://testing-library.com/docs/guiding-principles), but the main
takeaway is that you should be interacting with React as closely as possible to the way
the user will interact with it.
For example, they discourage using class or element name selectors to find components
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.
## 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
We have not found exhaustive unit testing of frontend code to be worth the trouble.
Rather, let's focus on testing non-obvious behavior.
In essence: `test behavior that wouldn't present itself to a developer playing around`.
Practically speaking, this means error states, interactive components, corner cases,
or anything that wouldn't come up in a demo course. Something a developer wouldn't
notice in the normal course of working in devstack.
## Snapshots
In practice, we've found snapshots of component trees to be too brittle to be worth it,
as refactors occur or external libraries change.
They can still be useful for data (like redux tests) or tiny isolated components.
But please avoid for any "interesting" component. Prefer inspecting the explicit behavior
under test, rather than just snapshotting the entire component tree.

View File

@@ -5,7 +5,23 @@ module.exports = createConfig('jest', {
'<rootDir>/src/setupTest.js',
],
coveragePathIgnorePatterns: [
'.*',
'src/setupTest.js',
'src/i18n',
'src/alerts/.*',
'src/assets/.*',
'src/course-header/.*',
'src/courseware/.*',
'src/data/.*',
'src/generic/.*',
'src/i18n/.*',
'src/index.jsx',
'src/index.scss',
'src/instructor-toolbar/.*',
'src/setupTest.js',
'src/store.js',
'src/tab-page/.*',
'src/toast/.*',
'src/utils.js',
],
});

9967
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,6 @@
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-learning#readme",
@@ -34,16 +29,16 @@
"url": "https://github.com/edx/frontend-app-learning/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-enterprise": "4.2.3",
"@edx/frontend-platform": "1.8.4",
"@edx/paragon": "12.3.1",
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-enterprise": "4.2.2",
"@edx/frontend-platform": "1.5.2",
"@edx/paragon": "10.1.1",
"@fortawesome/fontawesome-svg-core": "1.2.30",
"@fortawesome/free-brands-svg-icons": "5.13.1",
"@fortawesome/free-regular-svg-icons": "5.13.1",
"@fortawesome/free-solid-svg-icons": "5.13.1",
"@fortawesome/react-fontawesome": "0.1.14",
"@fortawesome/react-fontawesome": "0.1.11",
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
@@ -52,28 +47,26 @@
"react-break": "1.3.2",
"react-dom": "16.13.1",
"react-helmet": "6.0.0",
"react-redux": "7.2.2",
"react-redux": "7.2.1",
"react-router": "5.2.0",
"react-router-dom": "5.2.0",
"react-share": "4.2.1",
"redux": "4.0.5",
"regenerator-runtime": "0.13.7",
"reselect": "4.0.0",
"truncate-html": "1.0.3"
"reselect": "4.0.0"
},
"devDependencies": {
"@edx/frontend-build": "git+https://github.com/edx/frontend-build.git#alpha",
"@edx/frontend-build": "5.2.0",
"@testing-library/dom": "7.16.3",
"@testing-library/jest-dom": "5.10.1",
"@testing-library/react": "10.3.0",
"@testing-library/user-event": "12.0.17",
"axios-mock-adapter": "1.18.2",
"codecov": "3.7.2",
"es-check": "5.1.4",
"es-check": "5.1.0",
"glob": "7.1.6",
"husky": "3.1.0",
"jest": "24.9.0",
"jest-chain": "1.1.5",
"reactifex": "1.1.1",
"rosie": "2.0.1"
}

View File

@@ -1,13 +1,10 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Course | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
<% } %>
<title>Course | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,140 +1,24 @@
import React from 'react';
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 messages from './messages';
function AccessExpirationAlert({ intl, payload }) {
function AccessExpirationAlert({ payload }) {
const {
accessExpiration,
userTimezone,
rawHtml,
} = payload;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
if (!accessExpiration) {
return null;
}
const {
expirationDate,
masqueradingExpiredCourse,
upgradeDeadline,
upgradeUrl,
} = accessExpiration;
if (masqueradingExpiredCourse) {
return (
<Alert type={ALERT_TYPES.INFO}>
<FormattedMessage
id="learning.accessExpiration.expired"
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}
/>
),
}}
/>
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{intl.formatMessage(messages.upgradeNow)}
</Hyperlink>
</>
);
}
return (
return rawHtml && (
<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}
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
AccessExpirationAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
accessExpiration: PropTypes.shape({
expirationDate: PropTypes.string.isRequired,
masqueradingExpiredCourse: PropTypes.bool.isRequired,
upgradeDeadline: PropTypes.string,
upgradeUrl: PropTypes.string,
}).isRequired,
userTimezone: PropTypes.string.isRequired,
rawHtml: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(AccessExpirationAlert);
export default AccessExpirationAlert;

View File

@@ -3,16 +3,13 @@ import { useAlert } from '../../generic/user-messages';
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
function useAccessExpirationAlert(accessExpiration, userTimezone, topic) {
const isVisible = !!accessExpiration; // If it exists, show it.
const payload = {
accessExpiration,
userTimezone,
};
function useAccessExpirationAlert(courseExpiredMessage, topic) {
const rawHtml = courseExpiredMessage || null;
const isVisible = !!rawHtml; // If it exists, show it.
useAlert(isVisible, {
code: 'clientAccessExpirationAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
topic,
});

View File

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

View File

@@ -5,7 +5,6 @@ import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useModel } from '../../generic/model-store';
import { Alert, ALERT_TYPES } from '../../generic/user-messages';
import messages from './messages';
@@ -19,13 +18,8 @@ function EnrollmentAlert({ intl, payload }) {
isStaff,
} = payload;
const {
org,
} = useModel('courseHomeMeta', courseId);
const { enrollClickHandler, loading } = useEnrollClickHandler(
courseId,
org,
intl.formatMessage(messages.success),
);
@@ -39,8 +33,8 @@ function EnrollmentAlert({ intl, payload }) {
}
const button = canEnroll && (
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
{intl.formatMessage(messages.enrollNowSentence)}
<Button disabled={loading} variant="link" className="p-0 border-0 align-top" onClick={enrollClickHandler}>
{intl.formatMessage(messages.enroll)}
</Button>
);

View File

@@ -2,8 +2,6 @@
import React, {
useContext, useState, useCallback, useMemo,
} 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 { useModel } from '../../generic/model-store';
@@ -13,23 +11,14 @@ import { postCourseEnrollment } from './data/api';
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
export function useEnrollmentAlert(courseId) {
const { authenticatedUser } = useContext(AppContext);
const course = useModel('courseHomeMeta', courseId);
const course = useModel('courses', 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
* 1. the user is not enrolled,
* 2. the user is authenticated, AND
* 3. the course is private.
*/
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
const isVisible = course && course.isEnrolled !== undefined && !course.isEnrolled;
const payload = {
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
canEnroll: outline.enrollAlert.canEnroll,
courseId,
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
isStaff: course && course.isStaff,
extraText: outline.enrollAlert.extraText,
isStaff: course.isStaff,
};
useAlert(isVisible, {
@@ -41,7 +30,7 @@ export function useEnrollmentAlert(courseId) {
return { clientEnrollmentAlert: EnrollmentAlert };
}
export function useEnrollClickHandler(courseId, orgId, successText) {
export function useEnrollClickHandler(courseId, successText) {
const [loading, setLoading] = useState(false);
const { addFlash } = useContext(UserMessagesContext);
const enrollClickHandler = useCallback(() => {
@@ -55,10 +44,6 @@ export function useEnrollClickHandler(courseId, orgId, successText) {
topic: 'course',
});
setLoading(false);
sendTrackEvent('edx.bi.user.course-home.enrollment', {
org_key: orgId,
courserun_key: courseId,
});
global.location.reload();
});
}, [courseId]);

View File

@@ -11,15 +11,9 @@ const messages = defineMessages({
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.',
},
enrollNowInline: {
id: 'learning.enrollment.enrollNow.Inline',
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.',
enroll: {
id: 'learning.enrollment.enroll.now',
defaultMessage: 'Enroll Now',
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
},
success: {

View File

@@ -2,30 +2,23 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { Hyperlink } from '@edx/paragon';
import { Alert } from '../../generic/user-messages';
import genericMessages from '../../generic/messages';
import messages from './messages';
function LogistrationAlert({ intl }) {
const signIn = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(genericMessages.signInLowercase)}
</Hyperlink>
<a href={`${getLoginRedirectUrl(global.location.href)}`}>
{intl.formatMessage(messages.login)}
</a>
);
// 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.
const register = (
<Hyperlink
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(genericMessages.registerLowercase)}
</Hyperlink>
<a href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}>
{intl.formatMessage(messages.register)}
</a>
);
return (
@@ -33,7 +26,7 @@ function LogistrationAlert({ intl }) {
<FormattedMessage
id="learning.logistration.alert"
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={{
signIn,
register,

View File

@@ -2,20 +2,12 @@
import React, { useContext } from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
import { useModel } from '../../generic/model-store';
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
export function useLogistrationAlert(courseId) {
export function useLogistrationAlert() {
const { authenticatedUser } = useContext(AppContext);
const outline = useModel('outline', courseId);
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;
const isVisible = authenticatedUser === null;
useAlert(isVisible, {
code: 'clientLogistrationAlert',

View 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;

View File

@@ -1,87 +1,24 @@
import React from 'react';
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 { FormattedPricing } from '../../generic/upgrade-button';
import messages from './messages';
function OfferAlert({ intl, payload }) {
function OfferAlert({ payload }) {
const {
offer,
userTimezone,
rawHtml,
} = payload;
if (!offer) {
return null;
}
const {
code,
expirationDate,
percentage,
upgradeUrl,
} = offer;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
return rawHtml && (
<Alert type={ALERT_TYPES.INFO}>
{/* the first-purchase-offer-banner class can be removed post REV-1512 experiment */}
<span className="font-weight-bold first-purchase-offer-banner">
<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>),
}}
/>
&nbsp;
<Hyperlink
className="font-weight-bold"
style={{ textDecoration: 'underline' }}
destination={upgradeUrl}
>
{intl.formatMessage(messages.upgradeNow)}
</Hyperlink>
{/* eslint-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</Alert>
);
}
OfferAlert.propTypes = {
intl: intlShape.isRequired,
payload: PropTypes.shape({
offer: PropTypes.shape({
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,
rawHtml: PropTypes.string.isRequired,
}).isRequired,
};
export default injectIntl(OfferAlert);
export default OfferAlert;

View File

@@ -3,17 +3,14 @@ import { useAlert } from '../../generic/user-messages';
const OfferAlert = React.lazy(() => import('./OfferAlert'));
export function useOfferAlert(offer, userTimezone, topic) {
const isVisible = !!offer; // if it exists, show it.
const payload = {
offer,
userTimezone,
};
export function useOfferAlert(offerHtml, topic) {
const rawHtml = offerHtml || null;
const isVisible = !!rawHtml; // if it exists, show it.
useAlert(isVisible, {
code: 'clientOfferAlert',
topic,
payload: useMemo(() => payload, Object.values(payload).sort()),
payload: useMemo(() => ({ rawHtml }), [rawHtml]),
});
return { clientOfferAlert: OfferAlert };

View File

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

BIN
src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ function CourseTabsNavigation({
<div className="container-fluid">
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
>
{tabs.map(({ url, title, slug }) => (
<a

View File

@@ -1,34 +0,0 @@
import React from 'react';
import { initializeMockApp, render, screen } from '../setupTest';
import { CourseTabsNavigation } from './index';
describe('Course Tabs Navigation', () => {
beforeAll(async () => {
initializeMockApp();
});
it('renders without tabs', () => {
render(<CourseTabsNavigation tabs={[]} />);
expect(screen.getByRole('button', { name: 'More...' })).toBeInTheDocument();
});
it('renders with tabs', () => {
const tabs = [
{ url: 'http://test-url1', title: 'Item 1', slug: 'test1' },
{ url: 'http://test-url2', title: 'Item 2', slug: 'test2' },
];
const mockData = {
tabs,
activeTabSlug: tabs[0].slug,
};
render(<CourseTabsNavigation {...mockData} />);
expect(screen.getByRole('link', { name: tabs[0].title }))
.toHaveAttribute('href', tabs[0].url)
.toHaveClass('active');
expect(screen.getByRole('link', { name: tabs[1].title }))
.toHaveAttribute('href', tabs[1].url)
.not.toHaveClass('active');
});
});

View File

@@ -1,13 +1,14 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { useEnterpriseConfig } from '@edx/frontend-enterprise';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import logo from './assets/logo.svg';
function LinkedLogo({
href,
@@ -28,54 +29,52 @@ LinkedLogo.propTypes = {
alt: PropTypes.string.isRequired,
};
function Header({
courseOrg, courseNumber, courseTitle, intl,
export default function Header({
courseOrg, courseNumber, courseTitle,
}) {
const { authenticatedUser } = useContext(AppContext);
const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig(
const { enterpriseLearnerPortalLink } = useEnterpriseConfig(
authenticatedUser,
getConfig().ENTERPRISE_LEARNER_PORTAL_HOSTNAME,
getConfig().LMS_BASE_URL,
);
let headerLogo = (
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
);
if (enterpriseCustomerBrandingConfig && Object.keys(enterpriseCustomerBrandingConfig).length > 0) {
headerLogo = (
<LinkedLogo
className="logo"
href={enterpriseCustomerBrandingConfig.logoDestination}
src={enterpriseCustomerBrandingConfig.logo}
alt={enterpriseCustomerBrandingConfig.logoAltText}
/>
);
}
return (
<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">
{headerLogo}
<div className="container-fluid py-2 d-flex align-items-center ">
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={logo}
alt={getConfig().SITE_NAME}
/>
<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 m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
{authenticatedUser && (
<AuthenticatedUserDropdown
enterpriseLearnerPortalLink={enterpriseLearnerPortalLink}
username={authenticatedUser.username}
/>
)}
{!authenticatedUser && (
<AnonymousUserMenu />
)}
<Dropdown className="user-dropdown">
<Dropdown.Toggle variant="light">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span className="d-none d-md-inline">
{authenticatedUser.username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>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}>Order History</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
</header>
);
@@ -85,7 +84,6 @@ Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
};
Header.defaultProps = {
@@ -93,5 +91,3 @@ Header.defaultProps = {
courseNumber: null,
courseTitle: null,
};
export default injectIntl(Header);

View File

@@ -1,29 +0,0 @@
import React from 'react';
import {
authenticatedUser, initializeMockApp, render, screen,
} from '../setupTest';
import { Header } from './index';
describe('Header', () => {
beforeAll(async () => {
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
await initializeMockApp();
});
it('displays user button', () => {
render(<Header />);
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
});
it('displays course data', () => {
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
render(<Header {...courseData} />);
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>logo</title>
<desc>Created with Sketch.</desc>
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,46 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
'learn.navigation.course.tabs.label': {
id: 'learn.navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
dashboard: {
id: 'header.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'header.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'header.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'header.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'header.menu.orderHistory.label',
defaultMessage: 'Order History',
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: {
id: 'header.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
});
export default messages;

View File

@@ -61,16 +61,10 @@ export default function buildSimpleCourseBlocks(courseId, title, options = {}) {
)];
const sectionBlock = options.sectionBlock || Factory.build(
'block',
{
type: 'chapter',
display_name: 'Title of Section',
complete: options.complete || false,
resume_block: options.resumeBlock || false,
children: sequenceBlock.map(block => block.id),
},
{ type: 'chapter', children: sequenceBlock.map(block => block.id) },
{ courseId },
);
const courseBlock = options.courseBlock || Factory.build(
const courseBlock = options.courseBlocks || Factory.build(
'block',
{ type: 'course', display_name: title, children: [sectionBlock.id] },
{ courseId },

View File

@@ -2,7 +2,7 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep
Factory.define('courseHomeMetadata')
.sequence(
'courseId', (courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
'courseId', (courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`
)
.option('host', 'http://localhost:18000')
.attrs({
@@ -12,7 +12,6 @@ Factory.define('courseHomeMetadata')
org: 'edX',
title: 'Demonstration Course',
is_self_paced: false,
is_enrolled: false,
})
.attr(
'tabs', ['courseId', 'host'], (courseId, host) => {
@@ -25,7 +24,7 @@ Factory.define('courseHomeMetadata')
slug: 'courseware',
type: 'courseware',
},
{ courseId, path: 'course/' },
{ courseId: courseId, path: 'course/' },
),
Factory.build(
'tab',
@@ -35,7 +34,7 @@ Factory.define('courseHomeMetadata')
slug: 'discussion',
type: 'discussion',
},
{ courseId, path: 'discussion/forum/' },
{ courseId: courseId, path: 'discussion/forum/' },
),
Factory.build(
'tab',
@@ -45,7 +44,7 @@ Factory.define('courseHomeMetadata')
slug: 'wiki',
type: 'wiki',
},
{ courseId, path: 'course_wiki' },
{ courseId: courseId, path: 'course_wiki' },
),
Factory.build(
'tab',
@@ -55,7 +54,7 @@ Factory.define('courseHomeMetadata')
slug: 'progress',
type: 'progress',
},
{ courseId, path: 'progress' },
{ courseId: courseId, path: 'progress' },
),
Factory.build(
'tab',
@@ -65,17 +64,7 @@ Factory.define('courseHomeMetadata')
slug: 'instructor',
type: 'instructor',
},
{ courseId, path: 'instructor' },
),
Factory.build(
'tab',
{
title: 'Dates',
priority: 5,
slug: 'dates',
type: 'dates',
},
{ courseId, path: 'dates' },
{ courseId: courseId, path: 'instructor' },
),
];
@@ -84,7 +73,7 @@ Factory.define('courseHomeMetadata')
tab_id: tab.slug,
title: tab.title,
url: `${host}${tab.url}`,
}),
})
);
},
);

View File

@@ -1,223 +1,27 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
// Sample data helpful when developing & testing, to see a variety of configurations.
// This set of data is not realistic (mix of having access and not), but it
// is intended to demonstrate many UI results.
Factory.define('datesTabData')
.attrs({
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
},
course_date_blocks: [
{
date: '2020-05-01T17:59:41Z',
assigment_type: 'Homework',
date: '2013-02-05T05:00:00Z',
date_type: 'course-start-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Starts',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-04T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Multi Badges Completed',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-05T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Multi Badges Past Due',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-27T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Past Due 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-27T02:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Past Due 2',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-28T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Completed/Due 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2020-05-28T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Completed/Due 2',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-29T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Completed 1',
extra_info: null,
},
{
assignment_type: 'Homework',
complete: true,
date: '2020-05-29T08:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'Both Completed 2',
extra_info: null,
},
{
date: '2020-06-16T17:59:40.942669Z',
date_type: 'verified-upgrade-deadline',
description: "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
learner_has_access: true,
link: 'https://example.com/',
title: 'Upgrade to Verified Certificate',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-17T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: false,
link: 'https://example.com/',
title: 'One Verified 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-17T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Verified 2',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-17T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'ORA Verified 2',
extra_info: "ORA Dates are set by the instructor, and can't be changed",
},
{
assignment_type: 'Homework',
date: '2030-08-18T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: false,
link: 'https://example.com/',
title: 'Both Verified 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-18T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: false,
link: 'https://example.com/',
title: 'Both Verified 2',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-19T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'One Unreleased 1',
},
{
assignment_type: 'Homework',
date: '2030-08-19T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
link: 'https://example.com/',
title: 'One Unreleased 2',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-20T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Both Unreleased 1',
extra_info: null,
},
{
assignment_type: 'Homework',
date: '2030-08-20T05:59:40.942669Z',
date_type: 'assignment-due-date',
description: '',
learner_has_access: true,
title: 'Both Unreleased 2',
extra_info: null,
},
{
date: '2030-08-23T00:00:00Z',
date_type: 'course-end-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Ends',
extra_info: null,
},
{
date: '2030-09-01T00:00:00Z',
date_type: 'verification-deadline-date',
description: 'You must successfully complete verification before this date to qualify for a Verified Certificate.',
learner_has_access: false,
link: 'https://example.com/',
title: 'Verification Deadline',
extra_info: null,
extraInfo: '',
},
],
has_ended: false,
missed_deadlines: false,
missed_gated_content: false,
learner_is_full_access: true,
user_timezone: 'America/New_York',
user_timezone: null,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
});

View File

@@ -5,51 +5,30 @@ import buildSimpleCourseBlocks from './courseBlocks.factory';
Factory.define('outlineTabData')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
.option('dateBlocks', [])
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ([{
.attr('course_expired_html', [], () => '<div>Course expired</div>')
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ({
analytics_id: 'edx.bookmarks',
title: 'Bookmarks',
url: `${host}/courses/${courseId}/bookmarks/`,
}]))
}))
.attr('course_blocks', ['courseId'], courseId => {
const { courseBlocks } = buildSimpleCourseBlocks(courseId);
return {
blocks: courseBlocks.blocks,
};
})
.attr('dates_widget', ['dateBlocks'], (dateBlocks) => ({
course_date_blocks: dateBlocks,
user_timezone: 'UTC',
.attr('course_goals', [], () => ({
goal_options: [],
selected_goal: {},
}))
.attr('enroll_alert', {
can_enroll: true,
extra_text: 'Contact the administrator.',
})
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>')
.attr('offer_html', [], () => '<div>Great offer here</div>')
.attr('resume_course', ['host', 'courseId'], (host, courseId) => ({
has_visited_course: false,
url: `${host}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
}))
.attr('verified_mode', ['host'], (host) => ({
access_expiration_date: '2050-01-01T12:00:00',
currency: 'USD',
currency_symbol: '$',
price: 149,
sku: 'ABCD1234',
upgrade_url: `${host}/dashboard`,
}))
.attrs({
access_expiration: null,
can_show_upgrade_sock: true,
course_goals: {
goal_options: [],
selected_goal: null,
},
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
},
enroll_alert: {
can_enroll: true,
extra_text: 'Contact the administrator.',
},
handouts_html: '<ul><li>Handout 1</li></ul>',
offer: null,
welcome_message_html: '<p>Welcome to this course!</p>',
});
.attr('welcome_message_html', [], () => '<p>Welcome to this course!</p>');

View File

@@ -7,7 +7,7 @@ Object {
"courseStatus": "loaded",
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
"toastHeader": null,
},
"courseware": Object {
"courseId": null,
@@ -16,11 +16,10 @@ Object {
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"courses": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
@@ -28,7 +27,7 @@ Object {
"originalUserIsStaff": false,
"tabs": Array [
Object {
"slug": "outline",
"slug": "courseware",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
@@ -52,11 +51,6 @@ Object {
"title": "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",
},
@@ -65,220 +59,27 @@ Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseDateBlocks": Array [
Object {
"date": "2020-05-01T17:59:41Z",
"assigmentType": "Homework",
"date": "2013-02-05T05:00:00Z",
"dateType": "course-start-date",
"description": "",
"extraInfo": null,
"extraInfo": "",
"learnerHasAccess": true,
"link": "",
"title": "Course Starts",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Completed",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Multi Badges Past Due",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
},
Object {
"assignmentType": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 1",
},
Object {
"assignmentType": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Both Completed 2",
},
Object {
"date": "2020-06-16T17:59:40.942669Z",
"dateType": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "One Verified 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": "ORA Dates are set by the instructor, and can't be changed",
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Both Verified 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"learnerHasAccess": true,
"title": "One Unreleased 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 1",
},
Object {
"assignmentType": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"dateType": "assignment-due-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"title": "Both Unreleased 2",
},
Object {
"date": "2030-08-23T00:00:00Z",
"dateType": "course-end-date",
"description": "",
"extraInfo": null,
"learnerHasAccess": true,
"link": "",
"title": "Course Ends",
},
Object {
"date": "2030-09-01T00:00:00Z",
"dateType": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
"extraInfo": null,
"learnerHasAccess": false,
"link": "https://example.com/",
"title": "Verification Deadline",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
"hasEnded": false,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true,
"userTimezone": "America/New_York",
"missedDeadlines": false,
"missedGatedContent": false,
"userTimezone": null,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
@@ -292,7 +93,7 @@ Object {
"courseStatus": "loaded",
"toastBodyLink": null,
"toastBodyText": null,
"toastHeader": "",
"toastHeader": null,
},
"courseware": Object {
"courseId": null,
@@ -301,11 +102,10 @@ Object {
"sequenceStatus": "loading",
},
"models": Object {
"courseHomeMeta": Object {
"courses": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": false,
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
@@ -313,7 +113,7 @@ Object {
"originalUserIsStaff": false,
"tabs": Array [
Object {
"slug": "outline",
"slug": "courseware",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
@@ -337,19 +137,12 @@ Object {
"title": "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",
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"accessExpiration": null,
"canShowUpgradeSock": true,
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
@@ -365,11 +158,10 @@ Object {
"complete": false,
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"resumeBlock": false,
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
"title": "Title of Section",
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
},
},
"sequences": Object {
@@ -385,46 +177,28 @@ Object {
},
},
},
"courseExpiredHtml": "<div>Course expired</div>",
"courseGoals": Object {
"goalOptions": Array [],
"selectedGoal": null,
"selectedGoal": Object {},
},
"courseTools": Array [
Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"datesWidget": Object {
"courseDateBlocks": Array [],
"userTimezone": "UTC",
"courseTools": Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
},
"datesWidget": undefined,
"enrollAlert": Object {
"canEnroll": true,
"extraText": "Contact the administrator.",
},
"handoutsHtml": "<ul><li>Handout 1</li></ul>",
"hasEnded": undefined,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"offer": null,
"offerHtml": "<div>Great offer here</div>",
"resumeCourse": Object {
"hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
},
"verifiedMode": Object {
"accessExpirationDate": "2050-01-01T12:00:00",
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "ABCD1234",
"upgradeUrl": "http://localhost:18000/dashboard",
},
"welcomeMessageHtml": "<p>Welcome to this course!</p>",
},
},

View File

@@ -1,15 +1,13 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { logError } from '@edx/frontend-platform/logging';
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
...data,
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
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? 'outline' : tab.tabId,
slug: tab.tabId,
title: tab.title,
url: tab.url,
})),
@@ -37,7 +35,6 @@ export function normalizeOutlineBlocks(courseId, blocks) {
complete: block.complete,
id: block.id,
title: block.display_name,
resumeBlock: block.resume_block,
sequenceIds: block.children || [],
};
break;
@@ -55,7 +52,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
break;
default:
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
logError(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
}
});
@@ -76,7 +73,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
if (sequenceId in models.sequences) {
models.sequences[sequenceId].sectionId = section.id;
} else {
logInfo(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
logError(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`);
}
});
}
@@ -91,12 +88,7 @@ export async function getCourseHomeCourseMetadata(courseId) {
return normalizeCourseHomeCourseMetadata(data);
}
// For debugging purposes, you might like to see a fully loaded dates tab.
// Just uncomment the next few lines and the immediate 'return' in the function below
// import { Factory } from 'rosie';
// import './__factories__';
export async function getDatesTabData(courseId) {
// return camelCaseObject(Factory.build('datesTabData'));
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
@@ -107,10 +99,6 @@ export async function getDatesTabData(courseId) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
if (httpErrorStatus === 401) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`);
return {};
}
throw error;
}
}
@@ -130,20 +118,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) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
@@ -161,45 +135,34 @@ export async function getOutlineTabData(courseId) {
const {
data,
} = tabData;
const accessExpiration = camelCaseObject(data.access_expiration);
const canShowUpgradeSock = data.can_show_upgrade_sock;
const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
const courseBlocks = normalizeOutlineBlocks(courseId, data.course_blocks.blocks);
const courseGoals = camelCaseObject(data.course_goals);
const courseExpiredHtml = data.course_expired_html;
const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
const datesWidget = camelCaseObject(data.dates_widget);
const enrollAlert = camelCaseObject(data.enroll_alert);
const handoutsHtml = data.handouts_html;
const hasEnded = data.has_ended;
const offer = camelCaseObject(data.offer);
const offerHtml = data.offer_html;
const resumeCourse = camelCaseObject(data.resume_course);
const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html;
return {
accessExpiration,
canShowUpgradeSock,
courseBlocks,
courseGoals,
courseExpiredHtml,
courseTools,
datesBannerInfo,
datesWidget,
enrollAlert,
handoutsHtml,
hasEnded,
offer,
offerHtml,
resumeCourse,
verifiedMode,
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`);
return getAuthenticatedHttpClient().post(url.href, {
course_key: courseId,
research_event_data: { location: `${model}-tab` },
});
return getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
}
export async function postCourseGoals(courseId, goalKey) {
@@ -217,10 +180,7 @@ export async function postRequestCert(courseId) {
await getAuthenticatedHttpClient().post(url.href);
}
export async function executePostFromPostEvent(postData, researchEventData) {
export async function executePostFromPostEvent(postData) {
const url = new URL(postData.url);
return getAuthenticatedHttpClient().post(url.href, {
course_key: postData.bodyParams.courseId,
research_event_data: researchEventData,
});
return getAuthenticatedHttpClient().post(url.href, { course_key: postData.bodyParams.courseId });
}

View File

@@ -8,7 +8,7 @@ import * as thunks from './thunks';
import executeThunk from '../../utils';
import { initializeMockApp } from '../../setupTest';
import initializeMockApp from '../../setupTest';
import initializeStore from '../../store';
const { loggingService } = initializeMockApp();
@@ -17,7 +17,7 @@ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const { courseId } = courseHomeMetadata;
const courseId = courseHomeMetadata.courseId;
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
let store;
@@ -102,17 +102,16 @@ describe('Data layer integration tests', () => {
describe('Test resetDeadlines', () => {
it('Should reset course deadlines', async () => {
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
const model = 'dates';
axiosMock.onPost(resetUrl).reply(201, {});
const getTabDataMock = jest.fn(() => ({
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].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);
});

View File

@@ -12,7 +12,7 @@ const slice = createSlice({
courseId: null,
toastBodyText: null,
toastBodyLink: null,
toastHeader: '',
toastHeader: null,
},
reducers: {
fetchTabRequest: (state, { payload }) => {

View File

@@ -39,7 +39,7 @@ export function fetchTab(courseId, tab, getTabData) {
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courseHomeMeta',
modelType: 'courses',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
@@ -90,9 +90,9 @@ export function requestCert(courseId) {
return async () => postRequestCert(courseId);
}
export function resetDeadlines(courseId, model, getTabData) {
export function resetDeadlines(courseId, getTabData) {
return async (dispatch) => {
postCourseDeadlines(courseId, model).then(response => {
postCourseDeadlines(courseId).then(response => {
const { data } = response;
const {
header,
@@ -111,12 +111,9 @@ export async function saveCourseGoal(courseId, goalKey) {
export function processEvent(eventData, getTabData) {
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);
if (event.eventName === eventTypes.POST_EVENT) {
executePostFromPostEvent(event.postData, researchEventData).then(response => {
executePostFromPostEvent(event.postData).then(response => {
const { data } = response;
const {
header,

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from './messages';
@@ -15,17 +14,17 @@ function DatesBanner(props) {
return (
<div className="banner rounded my-4 p-4 container-fluid border border-primary-200 bg-info-100">
<div className="row w-100 m-0 justify-content-start justify-content-sm-between">
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-lg-9'}>
<div className={name === 'datesTabInfoBanner' ? 'col-12' : 'col-12 col-md-9'}>
<strong>
{intl.formatMessage(messages[`datesBanner.${name}.header`])}
</strong>
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
</div>
{bannerClickHandler && (
<div className="col-auto col-lg-3 p-lg-0 d-inline-flex align-items-center justify-content-start justify-content-lg-center">
<Button variant="outline-primary" className="align-self-center mt-3 mt-lg-0" onClick={bannerClickHandler}>
<div className="col-auto col-md-3 p-md-0 d-inline-flex align-items-center justify-content-start justify-content-md-center">
<button type="button" className="btn rounded align-self-center border border-primary bg-white mt-3 mt-md-0 font-weight-bold" onClick={bannerClickHandler}>
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
</Button>
</button>
</div>
)}
</div>

View File

@@ -5,19 +5,23 @@ import { useDispatch, useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
import DatesBanner from './DatesBanner';
import { resetDeadlines } from '../data';
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
function DatesBannerContainer(props) {
const {
model,
} = props;
function DatesBannerContainer({
courseDateBlocks,
datesBannerInfo,
hasEnded,
model,
tabFetch,
}) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel(model, courseId);
const {
contentTypeGatingEnabled,
missedDeadlines,
@@ -27,7 +31,7 @@ function DatesBannerContainer({
const {
isSelfPaced,
} = useModel('courseHomeMeta', courseId);
} = useModel('courses', courseId);
const dispatch = useDispatch();
const hasDeadlines = courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
@@ -54,7 +58,7 @@ function DatesBannerContainer({
{
name: 'resetDatesBanner',
shouldDisplay: resetDates,
clickHandler: () => dispatch(resetDeadlines(courseId, model, tabFetch)),
clickHandler: () => dispatch(resetDeadlines(courseId, fetchDatesTab)),
},
];
@@ -72,20 +76,7 @@ function DatesBannerContainer({
}
DatesBannerContainer.propTypes = {
courseDateBlocks: PropTypes.arrayOf(PropTypes.object).isRequired,
datesBannerInfo: PropTypes.shape({
contentTypeGatingEnabled: PropTypes.bool.isRequired,
missedDeadlines: PropTypes.bool.isRequired,
missedGatedContent: PropTypes.bool.isRequired,
verifiedUpgradeLink: PropTypes.string,
}).isRequired,
hasEnded: PropTypes.bool,
model: PropTypes.string.isRequired,
tabFetch: PropTypes.func.isRequired,
};
DatesBannerContainer.defaultProps = {
hasEnded: false,
};
export default DatesBannerContainer;

View File

@@ -4,10 +4,7 @@ import classNames from 'classnames';
export default function Badge({ children, className }) {
return (
<span
className={classNames('dates-badge small ml-2', className)}
data-testid="dates-badge"
>
<span className={classNames('dates-badge badge align-text-bottom font-italic ml-2 px-2 py-1', className)}>
{children}
</span>
);

View File

@@ -1,4 +1,3 @@
.dates-badge {
border-radius: 4px;
padding: 2px 8px 3px 8px;
font-size: 0.75rem;
}

View File

@@ -1,37 +1,17 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './Timeline';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchDatesTab } from '../data';
import { useModel } from '../../generic/model-store';
function DatesTab({ intl }) {
const {
courseId,
} = useSelector(state => state.courseHome);
const {
courseDateBlocks,
datesBannerInfo,
hasEnded,
} = useModel('dates', courseId);
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)}
</div>
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
model="dates"
tabFetch={fetchDatesTab}
/>
<DatesBannerContainer model="dates" />
<Timeline />
</>
);

View File

@@ -1,239 +0,0 @@
import React from 'react';
import { Route } from 'react-router';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig, history } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { waitForElementToBeRemoved } from '@testing-library/dom';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DatesTab from './DatesTab';
import { fetchDatesTab } from '../data';
import { fireEvent, initializeMockApp, waitFor } from '../../setupTest';
import initializeStore from '../../store';
import { TabContainer } from '../../tab-page';
import { UserMessagesProvider } from '../../generic/user-messages';
initializeMockApp();
describe('DatesTab', () => {
let axiosMock;
const store = initializeStore();
const component = (
<AppProvider store={store}>
<UserMessagesProvider>
<Route path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</Route>
</UserMessagesProvider>
</AppProvider>
);
// 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
// anything). Instead, we find elements by printed date (which is what the user sees) and data-testid. Which is
// better than assuming anything about how the surrounding elements are organized by div and span or whatever. And
// better than adding non-style class names.
// Hence the following getDay query helper.
async function getDay(date) {
const dateNode = await screen.findByText(date);
let parent = dateNode.parentElement;
while (parent) {
if (parent.dataset && parent.dataset.testid === 'dates-day') {
return {
day: parent,
header: within(parent).getByTestId('dates-header'),
items: within(parent).queryAllByTestId('dates-item'),
};
}
parent = parent.parentElement;
}
throw new Error('Did not find day container');
}
describe('when receiving a full set of dates data', () => {
beforeEach(() => {
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);
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
render(component);
});
it('handles unreleased & complete', async () => {
const { header } = await getDay('Sun, May 3, 2020');
const badges = within(header).getAllByTestId('dates-badge');
expect(badges).toHaveLength(2);
expect(badges[0]).toHaveTextContent('Completed');
expect(badges[1]).toHaveTextContent('Not yet released');
});
it('handles unreleased & past due', async () => {
const { header } = await getDay('Mon, May 4, 2020');
const badges = within(header).getAllByTestId('dates-badge');
expect(badges).toHaveLength(2);
expect(badges[0]).toHaveTextContent('Past due');
expect(badges[1]).toHaveTextContent('Not yet released');
});
it('handles verified only', async () => {
const { day } = await getDay('Sun, Aug 18, 2030');
const badge = within(day).getByTestId('dates-badge');
expect(badge).toHaveTextContent('Verified only');
});
it('verified only has no link', async () => {
const { day } = await getDay('Sun, Aug 18, 2030');
expect(within(day).queryByRole('link')).toBeNull();
});
it('same status items have header badge', async () => {
const { day, header } = await getDay('Tue, May 26, 2020');
const badge = within(header).getByTestId('dates-badge');
expect(badge).toHaveTextContent('Past due'); // one header badge
expect(within(day).getAllByTestId('dates-badge')).toHaveLength(1); // no other badges
});
it('different status items have individual badges', async () => {
const { header, items } = await getDay('Thu, May 28, 2020');
const headerBadges = within(header).queryAllByTestId('dates-badge');
expect(headerBadges).toHaveLength(0); // no header badges
expect(items).toHaveLength(2);
expect(within(items[0]).getByTestId('dates-badge')).toHaveTextContent('Completed');
expect(within(items[1]).getByTestId('dates-badge')).toHaveTextContent('Past due');
});
it('shows extra info', async () => {
const { items } = await getDay('Sat, Aug 17, 2030');
expect(items).toHaveLength(3);
const tipIcon = within(items[2]).getByTestId('dates-extra-info');
const tipText = "ORA Dates are set by the instructor, and can't be changed";
expect(screen.queryByText(tipText)).toBeNull(); // tooltip does not start in DOM
userEvent.hover(tipIcon);
const tooltip = screen.getByText(tipText); // now it's there
userEvent.unhover(tipIcon);
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. Dont worry—you wont lose any of the progress youve 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();
});
});
});

View File

@@ -27,7 +27,7 @@ function Day({
const { color, badges } = getBadgeListAndColor(date, intl, null, items);
return (
<li className="dates-day pb-4" data-testid="dates-day">
<li className="dates-day pb-4">
{/* Top Line */}
{!first && <div className="dates-line-top border-1 border-left border-gray-900 bg-gray-900" />}
@@ -39,7 +39,7 @@ function Day({
{/* Content */}
<div className="d-inline-block ml-3 pl-2">
<div className="mb-1" data-testid="dates-header">
<div className="mb-1">
<p className="d-inline text-dark-500 font-weight-bold">
<FormattedDate
value={date}
@@ -59,7 +59,7 @@ function Day({
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-dark-500' : 'text-dark-200';
return (
<div key={item.title + item.date} className={textColor} data-testid="dates-item">
<div key={item.title + item.date} className={textColor}>
<div>
<span className="font-weight-bold small mt-1">
{item.assignmentType && `${item.assignmentType}: `}{title}
@@ -72,7 +72,7 @@ function Day({
<Tooltip>{item.extraInfo}</Tooltip>
}
>
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" data-testid="dates-extra-info" />
<FontAwesomeIcon icon={faInfoCircle} className="fa-xs ml-1 text-gray-700" />
</OverlayTrigger>
)}
</div>

View File

@@ -37,22 +37,19 @@ function getBadgeListAndColor(date, intl, item, items) {
{
message: messages.today,
shownForDay: isToday,
bg: 'bg-warning-300',
className: 'text-gray-900',
bg: 'dates-bg-today',
},
{
message: messages.completed,
shownForDay: assignments.length && assignments.every(isComplete),
shownForItem: x => isLearnerAssignment(x) && isComplete(x),
bg: 'bg-dark-100',
className: 'text-gray-900',
},
{
message: messages.pastDue,
shownForDay: assignments.length && assignments.every(isPastDue),
shownForItem: x => isLearnerAssignment(x) && isPastDue(x),
bg: 'bg-dark-200',
className: 'text-white',
},
{
message: messages.dueNext,
@@ -65,7 +62,7 @@ function getBadgeListAndColor(date, intl, item, items) {
message: messages.unreleased,
shownForDay: assignments.length && assignments.every(isUnreleased),
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,

View File

@@ -0,0 +1,228 @@
// Sample data helpful when developing, to see a variety of configurations.
// This set of data is not realistic (mix of having access and not), but it
// is intended to demonstrate many UI results.
// To use, have getDatesTabData in api.js return the result of this call instead:
/*
import fakeDatesData from '../dates-tab/fakeData';
export async function getDatesTabData(courseId, version) {
if (tab === 'dates') { return camelCaseObject(fakeDatesData()); }
...
}
*/
export default function fakeDatesData() {
return JSON.parse(`
{
"course_date_blocks": [
{
"date": "2020-05-01T17:59:41Z",
"date_type": "course-start-date",
"description": "",
"learner_has_access": true,
"link": "",
"title": "Course Starts",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-04T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Multi Badges Completed",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-05T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Multi Badges Past Due",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Past Due 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-27T02:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Past Due 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-28T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Completed/Due 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2020-05-28T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Completed/Due 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Completed 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"complete": true,
"date": "2020-05-29T08:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Both Completed 2",
"extra_info": null
},
{
"date": "2020-06-16T17:59:40.942669Z",
"date_type": "verified-upgrade-deadline",
"description": "Don't miss the opportunity to highlight your new knowledge and skills by earning a verified certificate.",
"learner_has_access": true,
"link": "https://example.com/",
"title": "Upgrade to Verified Certificate",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": false,
"link": "https://example.com/",
"title": "One Verified 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Verified 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-17T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "ORA Verified 2",
"extra_info": "ORA Dates are set by the instructor, and can't be changed"
},
{
"assignment_type": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": false,
"link": "https://example.com/",
"title": "Both Verified 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-18T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": false,
"link": "https://example.com/",
"title": "Both Verified 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "One Unreleased 1"
},
{
"assignment_type": "Homework",
"date": "2030-08-19T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"link": "https://example.com/",
"title": "One Unreleased 2",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Both Unreleased 1",
"extra_info": null
},
{
"assignment_type": "Homework",
"date": "2030-08-20T05:59:40.942669Z",
"date_type": "assignment-due-date",
"description": "",
"learner_has_access": true,
"title": "Both Unreleased 2",
"extra_info": null
},
{
"date": "2030-08-23T00:00:00Z",
"date_type": "course-end-date",
"description": "",
"learner_has_access": true,
"link": "",
"title": "Course Ends",
"extra_info": null
},
{
"date": "2030-09-01T00:00:00Z",
"date_type": "verification-deadline-date",
"description": "You must successfully complete verification before this date to qualify for a Verified Certificate.",
"learner_has_access": false,
"link": "https://example.com/",
"title": "Verification Deadline",
"extra_info": null
}
],
"display_reset_dates_text": false,
"learner_is_verified": false,
"user_timezone": "America/New_York",
"verified_upgrade_link": "https://example.com/"
}
`);
}

View File

@@ -7,15 +7,15 @@ const messages = defineMessages({
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due next',
defaultMessage: 'Due Next',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past due',
defaultMessage: 'Past Due',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important dates',
defaultMessage: 'Important Dates',
},
today: {
id: 'learning.dates.badge.today',
@@ -23,11 +23,11 @@ const messages = defineMessages({
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not yet released',
defaultMessage: 'Not Yet Released',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified only',
defaultMessage: 'Verified Only',
},
});

View File

@@ -13,10 +13,10 @@ export default function DateSummary({
const linkedTitle = dateBlock.link && isLearnerAssignment(dateBlock);
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
return (
<li className="container p-0 mb-3 small text-dark-500">
<section className="container p-0 mb-3">
<div className="row">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" fixedWidth />
<div className="ml-1 font-weight-bold">
<FontAwesomeIcon icon={faCalendarAlt} className="ml-3 mt-1 mr-1" style={{ width: '20px' }} />
<div className="ml-2 font-weight-bold">
<FormattedDate
value={dateBlock.date}
day="numeric"
@@ -27,7 +27,7 @@ export default function DateSummary({
/>
</div>
</div>
<div className="row ml-4 pr-2">
<div className="row ml-4 px-2">
<div className="date-summary-text">
{linkedTitle
&& <div className="font-weight-bold mt-2"><a href={dateBlock.link}>{dateBlock.title}</a></div>}
@@ -35,11 +35,12 @@ export default function DateSummary({
&& <div className="font-weight-bold mt-2">{dateBlock.title}</div>}
</div>
{dateBlock.description
&& <div className="date-summary-text mt-1">{dateBlock.description}</div>}
&& <div className="date-summary-text m-0 mt-1">{dateBlock.description}</div>}
{!linkedTitle && dateBlock.link
&& <a href={dateBlock.link} className="description-link">{dateBlock.linkText}</a>}
</div>
</li>
</section>
);
}

View File

@@ -1,11 +0,0 @@
body a {
color: #00688D;
}
body.inline-link a {
text-decoration: underline;
}
body.small {
font-size: 0.875rem;
}

View File

@@ -3,22 +3,16 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
export default function LmsHtmlFragment({
className,
html,
title,
...rest
}) {
export default function LmsHtmlFragment({ html, title, ...rest }) {
const wholePage = `
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<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>
<body class="${className}">${html}</body>
</html>
`;
<html>
<head>
<base href="${getConfig().LMS_BASE_URL}" target="_parent">
<link rel="stylesheet" href="/static/css/bootstrap/lms-main.css">
</head>
<body>${html}</body>
</html>
`;
const iframe = useRef(null);
function handleLoad() {
@@ -39,12 +33,7 @@ export default function LmsHtmlFragment({
);
}
LmsHtmlFragment.defaultProps = {
className: '',
};
LmsHtmlFragment.propTypes = {
className: PropTypes.string,
html: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};

View File

@@ -1,32 +1,26 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Toast } from '@edx/paragon';
import { AlertList } from '../../generic/user-messages';
import CourseDates from './widgets/CourseDates';
import CourseGoalCard from './widgets/CourseGoalCard';
import CourseHandouts from './widgets/CourseHandouts';
import CourseSock from '../../generic/course-sock';
import CourseTools from './widgets/CourseTools';
import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
import { fetchOutlineTab } from '../data';
import genericMessages from '../../generic/messages';
import LearningToast from '../../toast/LearningToast';
import messages from './messages';
import Section from './Section';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeCard from './widgets/UpgradeCard';
import useAccessExpirationAlert from '../../alerts/access-expiration-alert';
import useCertificateAvailableAlert from './alerts/certificate-available-alert';
import useCourseEndAlert from './alerts/course-end-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 usePrivateCourseAlert from './alerts/private-course-alert';
import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
function OutlineTab({ intl }) {
const {
@@ -34,13 +28,16 @@ function OutlineTab({ intl }) {
} = useSelector(state => state.courseHome);
const {
org,
title,
} = useModel('courseHomeMeta', courseId);
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
} = useModel('courses', courseId);
const {
accessExpiration,
canShowUpgradeSock,
courseBlocks: {
courses,
sections,
@@ -49,76 +46,66 @@ function OutlineTab({ intl }) {
goalOptions,
selectedGoal,
},
datesBannerInfo,
datesWidget: {
courseDateBlocks,
userTimezone,
},
hasEnded,
courseExpiredHtml,
resumeCourse: {
hasVisitedCourse,
url: resumeCourseUrl,
},
offer,
verifiedMode,
offerHtml,
} = useModel('outline', courseId);
const [courseGoalToDisplay, setCourseGoalToDisplay] = useState(selectedGoal);
const [goalToastHeader, setGoalToastHeader] = useState('');
const [expandAll, setExpandAll] = useState(false);
const [goalToastHeader, setGoalToastHeader] = useState(null);
const logResumeCourseClick = () => {
sendTrackingLogEvent('edx.course.home.resume_course.clicked', {
courserun_key: courseId,
event_type: hasVisitedCourse ? 'resume' : 'start',
org_key: org,
url: resumeCourseUrl,
});
};
// Above the tab alerts (appearing in the order listed here)
const logistrationAlert = useLogistrationAlert();
const enrollmentAlert = useEnrollmentAlert(courseId);
// Below the course title alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(offer, userTimezone, 'outline-course-alerts');
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'outline-course-alerts');
const offerAlert = useOfferAlert(offerHtml, 'outline-course-alerts');
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredHtml, 'outline-course-alerts');
const courseStartAlert = useCourseStartAlert(courseId);
const courseEndAlert = useCourseEndAlert(courseId);
const certificateAvailableAlert = useCertificateAvailableAlert(courseId);
const privateCourseAlert = usePrivateCourseAlert(courseId);
const rootCourseId = courses && Object.keys(courses)[0];
const courseSock = useRef(null);
const rootCourseId = Object.keys(courses)[0];
const { sectionIds } = courses[rootCourseId];
return (
<>
<Toast
closeLabel={intl.formatMessage(genericMessages.close)}
onClose={() => setGoalToastHeader('')}
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
...enrollmentAlert,
...logistrationAlert,
}}
/>
<LearningToast
header={goalToastHeader}
onClose={() => setGoalToastHeader(null)}
show={!!(goalToastHeader)}
>
{goalToastHeader}
</Toast>
<div className="row w-100 m-0 mb-3 justify-content-between">
<div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div>
</div>
/>
<div className="d-flex justify-content-between mb-3">
<div role="heading" aria-level="1" className="h4">{title}</div>
{resumeCourseUrl && (
<div className="col-12 col-sm-auto p-0">
<Button block href={resumeCourseUrl} onClick={() => logResumeCourseClick()}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</Button>
</div>
<a className="btn btn-primary" href={resumeCourseUrl}>
{hasVisitedCourse ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.start)}
</a>
)}
</div>
<div className="row">
<div className="col-12">
<AlertList
topic="outline-private-alerts"
customAlerts={{
...privateCourseAlert,
}}
/>
</div>
<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); }}
/>
)}
<WelcomeMessage courseId={courseId} />
<AlertList
topic="outline-course-alerts"
className="mb-3"
@@ -130,88 +117,41 @@ function OutlineTab({ intl }) {
...offerAlert,
}}
/>
{courseDateBlocks && (
<DatesBannerContainer
courseDateBlocks={courseDateBlocks}
datesBannerInfo={datesBannerInfo}
hasEnded={hasEnded}
model="outline"
tabFetch={fetchOutlineTab}
{sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
section={sections[sectionId]}
/>
)}
{!courseGoalToDisplay && goalOptions.length > 0 && (
<CourseGoalCard
))}
</div>
<div className="col col-12 col-md-4">
{courseGoalToDisplay && goalOptions.length > 0 && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
title={title}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<WelcomeMessage courseId={courseId} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">
<div className="col-12 col-sm-auto p-0">
<Button variant="outline-primary" block onClick={() => { setExpandAll(!expandAll); }}>
{expandAll ? intl.formatMessage(messages.collapseAll) : intl.formatMessage(messages.expandAll)}
</Button>
</div>
</div>
<ol className="list-unstyled">
{courses[rootCourseId].sectionIds.map((sectionId) => (
<Section
key={sectionId}
courseId={courseId}
defaultOpen={sections[sectionId].resumeBlock}
expand={expandAll}
section={sections[sectionId]}
/>
))}
</ol>
</>
)}
<CourseTools
courseId={courseId}
/>
<CourseDates
start={start}
end={end}
enrollmentStart={enrollmentStart}
enrollmentEnd={enrollmentEnd}
enrollmentMode={enrollmentMode}
isEnrolled={isEnrolled}
courseId={courseId}
/>
<CourseHandouts
courseId={courseId}
/>
</div>
{rootCourseId && (
<div className="col col-12 col-md-4">
<ProctoringInfoPanel
courseId={courseId}
/>
{courseGoalToDisplay && goalOptions.length > 0 && (
<UpdateGoalSelector
courseId={courseId}
goalOptions={goalOptions}
selectedGoal={courseGoalToDisplay}
setGoalToDisplay={(newGoal) => { setCourseGoalToDisplay(newGoal); }}
setGoalToastHeader={(newHeader) => { setGoalToastHeader(newHeader); }}
/>
)}
<CourseTools
courseId={courseId}
/>
<UpgradeCard
courseId={courseId}
onLearnMore={canShowUpgradeSock ? () => { courseSock.current.showToUser(); } : null}
/>
<CourseDates
courseId={courseId}
/>
<CourseHandouts
courseId={courseId}
/>
</div>
)}
</div>
{canShowUpgradeSock && (
<CourseSock
courseId={courseId}
offer={offer}
orgKey={org}
pageLocation="Home Page"
ref={courseSock}
verifiedMode={verifiedMode}
/>
)}
</>
);
}

View File

@@ -1,537 +0,0 @@
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import userEvent from '@testing-library/user-event';
import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory';
import {
fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, act,
} from '../../setupTest';
import executeThunk from '../../utils';
import * as thunks from '../data/thunks';
import initializeStore from '../../store';
import OutlineTab from './OutlineTab';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('Outline Tab', () => {
let axiosMock;
const courseId = 'course-v1:edX+Test+run';
const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
const enrollmentUrl = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
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 proctoringInfoUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}`;
const store = initializeStore();
const defaultMetadata = Factory.build('courseHomeMetadata', { courseId });
const defaultTabData = Factory.build('outlineTabData');
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseHomeMetadata', { courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
function setTabData(attributes, options) {
const outlineTabData = Factory.build('outlineTabData', attributes, options);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
}
async function fetchAndRender() {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
await act(async () => render(<OutlineTab />, { store }));
}
beforeEach(async () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Set defaults for network requests
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onPost(enrollmentUrl).reply(200, {});
axiosMock.onPost(goalUrl).reply(200, { header: 'Success' });
axiosMock.onGet(outlineUrl).reply(200, defaultTabData);
axiosMock.onGet(proctoringInfoUrl).reply(200, { onboarding_status: 'created', onboarding_link: 'test' });
logUnhandledRequests(axiosMock);
});
describe('Course Outline', () => {
it('displays link to start course', async () => {
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument();
});
it('displays link to resume course', async () => {
setTabData({
resume_course: {
has_visited_course: true,
url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`,
},
});
await fetchAndRender();
expect(screen.getByRole('link', { name: 'Resume course' })).toBeInTheDocument();
});
it('expands section that contains resume block', async () => {
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { resumeBlock: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
});
it('handles expand/collapse all button click', async () => {
await fetchAndRender();
// Button renders as "Expand All"
const expandButton = screen.getByRole('button', { name: 'Expand all' });
expect(expandButton).toBeInTheDocument();
// Section initially renders collapsed
const collapsedSectionNode = screen.getByRole('button', { name: /section/ });
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
// Click to expand section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true');
// Click to collapse section
userEvent.click(expandButton);
expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false');
});
it('displays correct icon for complete assignment', async () => {
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: true });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Completed section')).toBeInTheDocument();
});
it('displays correct icon for incomplete assignment', async () => {
const { courseBlocks } = await buildSimpleCourseBlocks(courseId, 'Title', { complete: false });
setTabData({
course_blocks: { blocks: courseBlocks.blocks },
});
await fetchAndRender();
expect(screen.getByTitle('Incomplete section')).toBeInTheDocument();
});
});
describe('Welcome Message', () => {
beforeEach(() => {
setMetadata({ is_enrolled: true });
});
it('does not render show more/less button under 100 words', async () => {
await fetchAndRender();
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
});
describe('over 100 words', () => {
beforeEach(async () => {
setTabData({
welcome_message_html: '<p>'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ 'This is a test welcome message that happens to be longer than one hundred words. We hope it will be shortened.'
+ '</p>',
});
await fetchAndRender();
});
it('shortens message', async () => {
expect(screen.getByTestId('short-welcome-message-iframe')).toBeInTheDocument();
const showMoreButton = screen.queryByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
it('renders show more/less button and handles click', async () => {
expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument();
let showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
userEvent.click(showMoreButton);
let showLessButton = screen.getByRole('button', { name: 'Show Less' });
expect(showLessButton).toBeInTheDocument();
expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument();
userEvent.click(showLessButton);
showLessButton = screen.queryByRole('button', { name: 'Show Less' });
expect(showLessButton).not.toBeInTheDocument();
showMoreButton = screen.getByRole('button', { name: 'Show More' });
expect(showMoreButton).toBeInTheDocument();
});
});
it('does not display if no update available', async () => {
setTabData({ welcome_message_html: null });
await fetchAndRender();
expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument();
});
});
describe('Course Goals', () => {
const goalOptions = [
['certify', 'Earn a certificate'],
['complete', 'Complete the course'],
['explore', 'Explore the course'],
['unsure', 'Not sure yet'],
];
it('does not render goal widgets if no goals available', async () => {
await fetchAndRender();
expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument();
});
describe('goal is not set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: null,
},
});
await fetchAndRender();
});
it('renders goal card', () => {
expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument();
expect(screen.getByTestId('course-goal-card')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument();
});
it('renders goal selector on goal selection', async () => {
const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' });
fireEvent.click(certifyGoalButton);
const goalSelector = await screen.findByTestId('edit-goal-selector');
expect(goalSelector).toBeInTheDocument();
});
});
describe('goal is set', () => {
beforeEach(async () => {
setTabData({
course_goals: {
goal_options: goalOptions,
selected_goal: { text: 'Earn a certificate', key: 'certify' },
},
});
await fetchAndRender();
});
it('renders edit goal selector', () => {
expect(screen.getByLabelText('Goal')).toBeInTheDocument();
expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument();
});
it('updates goal on click', async () => {
// Open dropdown
const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' });
await waitFor(() => {
expect(dropdownButtonNode).toBeInTheDocument();
});
fireEvent.click(dropdownButtonNode);
// Select a new goal
const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' });
await waitFor(() => {
expect(unsureButtonNode).toBeInTheDocument();
});
fireEvent.click(unsureButtonNode);
// Verify the request was made
await waitFor(() => {
expect(axiosMock.history.post[0].url).toMatch(goalUrl);
expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`);
});
});
});
});
describe('Course Handouts', () => {
it('renders title when handouts are available', async () => {
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument();
});
it('does not display title if no handouts available', async () => {
setTabData({ handouts_html: null });
await fetchAndRender();
expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument();
});
});
describe('Alert List', () => {
describe('Private Course Alert', () => {
it('does not display alert for enrolled user', async () => {
setMetadata({ is_enrolled: true });
await fetchAndRender();
expect(screen.queryByRole('button', { name: 'Enroll now' })).not.toBeInTheDocument();
expect(screen.queryByText('to access the full course')).not.toBeInTheDocument();
});
it('does not display enrollment button if enrollment is not available', async () => {
setTabData({
enroll_alert: {
can_enroll: false,
},
});
await fetchAndRender();
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 () => {
await fetchAndRender();
const alert = await screen.findByText('Welcome to Demonstration Course');
expect(alert.parentElement).toHaveAttribute('role', 'alert');
expect(screen.getByRole('button', { name: 'Enroll now' })).toBeInTheDocument();
});
it('handles button click', async () => {
const { location } = window;
delete window.location;
window.location = {
reload: jest.fn(),
};
await fetchAndRender();
const button = await screen.findByRole('button', { name: 'Enroll now' });
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].data)
.toEqual(JSON.stringify({ course_details: { course_id: courseId } }));
expect(window.location.reload).toHaveBeenCalledTimes(1);
window.location = location;
});
});
describe('Access Expiration Alert', () => {
it('has special masquerade text', async () => {
setTabData({
access_expiration: {
expiration_date: '2020-01-01T12:00:00Z',
masquerading_expired_course: true,
upgrade_deadline: null,
upgrade_url: null,
},
});
await fetchAndRender();
await screen.findByText('This learner does not have access to this course.', { exact: false });
});
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 });
});
});
describe('Course Start Alert', () => {
// Only appears if enrolled and before start of course
it('appears several days out', async () => {
const startDate = new Date();
startDate.setDate(startDate.getDate() + 100);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
});
it('appears today', async () => {
const startDate = new Date();
startDate.setHours(startDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-start-date',
date: startDate.toISOString(),
title: 'Start',
},
],
});
await fetchAndRender();
const node = await screen.findByText('Course starts', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
});
});
describe('Course End Alert', () => {
// Only appears if enrolled and within 14 days before the end of course
it('appears several days out', async () => {
const endDate = new Date();
endDate.setDate(endDate.getDate() + 13);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-end-date',
date: endDate.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
const node = await screen.findByText('This course is ending', { exact: false });
expect(node.textContent).toMatch(/.* on .*/); // several days away uses "on" before date
});
it('appears today', async () => {
const endDate = new Date();
endDate.setHours(endDate.getHours() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-end-date',
date: endDate.toISOString(),
title: 'End',
},
],
});
await fetchAndRender();
const node = await screen.findByText('This course is ending', { exact: false });
expect(node.textContent).toMatch(/.* at .*/); // same day uses "at" before date
});
});
describe('Certificate Available Alert', () => {
// Must satisfy two conditions for alert to appear: enrolled and between course end and cert availability
it('appears', async () => {
const now = new Date();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
setMetadata({ is_enrolled: true });
setTabData({}, {
dateBlocks: [
{
date_type: 'course-end-date',
date: yesterday.toISOString(),
title: 'End',
},
{
date_type: 'certificate-available-date',
date: tomorrow.toISOString(),
title: 'Cert Available',
},
],
});
await fetchAndRender();
await screen.findByText('We are working on generating course certificates.');
});
});
});
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();
});
});
});

View File

@@ -1,22 +1,12 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, IconButton } from '@edx/paragon';
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { Collapsible } from '@edx/paragon';
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SequenceLink from './SequenceLink';
import { useModel } from '../../generic/model-store';
import genericMessages from '../../generic/messages';
import messages from './messages';
function Section({
courseId,
defaultOpen,
expand,
intl,
section,
}) {
export default function Section({ courseId, section }) {
const {
complete,
sequenceIds,
@@ -28,91 +18,29 @@ function Section({
},
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
setOpen(expand);
}, [expand]);
useEffect(() => {
setOpen(defaultOpen);
}, []);
const sectionTitle = (
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left mt-1 text-success"
aria-hidden="true"
title={intl.formatMessage(messages.completedSection)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left mt-1 text-gray-400"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteSection)}
/>
)}
</div>
<div className="col-10 ml-3 p-0 font-weight-bold text-dark-500">
{title}
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
</div>
<div>
{complete && <FontAwesomeIcon icon={faCheckCircle} className="float-left text-success mt-1" />}
<div className="ml-4 font-weight-bold">{title}</div>
</div>
);
return (
<li>
<Collapsible
className="mb-2"
styling="card-lg"
title={sectionTitle}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
<IconButton
alt={intl.formatMessage(messages.openSection)}
icon={faPlus}
onClick={() => { setOpen(true); }}
/>
)}
iconWhenOpen={(
<IconButton
alt={intl.formatMessage(genericMessages.close)}
icon={faMinus}
onClick={() => { setOpen(false); }}
/>
)}
>
<ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol>
</Collapsible>
</li>
<Collapsible className="mb-2" styling="card-lg" title={sectionTitle} defaultOpen>
{sequenceIds.map((sequenceId, index) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</Collapsible>
);
}
Section.propTypes = {
courseId: PropTypes.string.isRequired,
defaultOpen: PropTypes.bool.isRequired,
expand: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
section: PropTypes.shape().isRequired,
};
export default injectIntl(Section);

View File

@@ -2,22 +2,21 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { FormattedMessage, FormattedTime } from '@edx/frontend-platform/i18n';
import { faClock, faEdit } from '@fortawesome/free-regular-svg-icons';
import {
FormattedMessage,
FormattedTime,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons';
faCheck,
faCheckCircle,
faExclamationTriangle,
faSpinner,
faTimesCircle,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useModel } from '../../generic/model-store';
import messages from './messages';
function SequenceLink({
export default function SequenceLink({
id,
intl,
courseId,
first,
sequence,
@@ -26,6 +25,7 @@ function SequenceLink({
complete,
description,
due,
icon,
showLink,
title,
} = sequence;
@@ -37,73 +37,74 @@ function SequenceLink({
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const displayTitle = showLink ? <Link to={`/course/${courseId}/${id}`}>{title}</Link> : title;
let text = title;
let faIcon;
switch (icon) {
// list of possible ones here: https://github.com/edx/edx-proctoring/blob/master/edx_proctoring/api.py
case 'fa-check': faIcon = faCheck; break;
case 'fa-clock-o': faIcon = faClock; break;
case 'fa-exclamation-triangle': faIcon = faExclamationTriangle; break;
case 'fa-pencil-square-o': faIcon = faEdit; break;
case 'fa-spinner fa-spin': faIcon = faSpinner; break;
case 'fa-times-circle': faIcon = faTimesCircle; break;
default: faIcon = null; break;
}
if (faIcon) {
text = <><FontAwesomeIcon icon={faIcon} /> {text}</>;
}
if (due) {
text = (
<>
{text}<br />
<small className="text-body">
<FormattedMessage
id="learning.outline.sequence-due"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
</small>
</>
);
}
text = <div className="ml-4">{text}</div>;
if (complete) {
text = <><FontAwesomeIcon icon={faCheckCircle} className="float-left text-success mt-1" />{text}</>;
}
// Do link last so we include everything above in the link
if (showLink) {
text = <Link to={`/course/${courseId}/${id}`}><div>{text}</div></Link>;
}
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
<div className="row w-100 m-0">
<div className="col-auto p-0">
{complete ? (
<FontAwesomeIcon
icon={fasCheckCircle}
fixedWidth
className="float-left text-success mt-1"
aria-hidden="true"
title={intl.formatMessage(messages.completedAssignment)}
/>
) : (
<FontAwesomeIcon
icon={farCheckCircle}
fixedWidth
className="float-left text-gray-400 mt-1"
aria-hidden="true"
title={intl.formatMessage(messages.incompleteAssignment)}
/>
)}
</div>
<div className="col-10 p-0 ml-3 text-break">{displayTitle}</div>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)}
</span>
</div>
{due && (
<div className="row w-100 m-0 ml-3 pl-3">
<small className="text-body pl-2">
<FormattedMessage
id="learning.outline.sequence-due"
defaultMessage="{description} due {assignmentDue}"
description="Used below an assignment title"
values={{
assignmentDue: (
<FormattedTime
key={`${id}-due`}
day="numeric"
month="short"
year="numeric"
hour12={false}
timeZoneName="short"
value={due}
{...timezoneFormatArgs}
/>
),
description: description || '',
}}
/>
</small>
</div>
)}
</div>
</li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
{text}
</div>
);
}
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
first: PropTypes.bool.isRequired,
sequence: PropTypes.shape().isRequired,
};
export default injectIntl(SequenceLink);

View File

@@ -9,15 +9,14 @@ const CertificateAvailableAlert = React.lazy(() => import('./CertificateAvailabl
function useCertificateAvailableAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
userTimezone,
},
} = useModel('outline', courseId);
const authenticatedUser = getAuthenticatedUser();
const username = authenticatedUser ? authenticatedUser.username : '';
const { username } = getAuthenticatedUser();
const certBlock = courseDateBlocks.find(b => b.dateType === 'certificate-available-date');
const endBlock = courseDateBlocks.find(b => b.dateType === 'course-end-date');

View File

@@ -13,6 +13,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseEndAlert({ payload }) {
const {
delta,
description,
endDate,
userTimezone,
@@ -29,7 +30,6 @@ function CourseEndAlert({ payload }) {
);
let msg;
const delta = new Date(endDate) - new Date();
if (delta < DAY_MS) {
const courseEndTime = (
<FormattedTime
@@ -88,6 +88,7 @@ function CourseEndAlert({ payload }) {
CourseEndAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
description: PropTypes.string,
endDate: PropTypes.string,
userTimezone: PropTypes.string,

View File

@@ -11,7 +11,7 @@ const WARNING_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
export function useCourseEndAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
@@ -24,6 +24,7 @@ export function useCourseEndAlert(courseId) {
const delta = endBlock ? endDate - new Date() : 0;
const isVisible = isEnrolled && endBlock && delta > 0 && delta < WARNING_PERIOD_MS;
const payload = {
delta,
description: endBlock && endBlock.description,
endDate: endBlock && endBlock.date,
userTimezone,

View File

@@ -13,6 +13,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; // in ms
function CourseStartAlert({ payload }) {
const {
delta,
startDate,
userTimezone,
} = payload;
@@ -27,7 +28,6 @@ function CourseStartAlert({ payload }) {
/>
);
const delta = new Date(startDate) - new Date();
if (delta < DAY_MS) {
return (
<Alert type={ALERT_TYPES.INFO}>
@@ -88,6 +88,7 @@ function CourseStartAlert({ payload }) {
CourseStartAlert.propTypes = {
payload: PropTypes.shape({
delta: PropTypes.number,
startDate: PropTypes.string,
userTimezone: PropTypes.string,
}).isRequired,

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useAlert } from '../../../../generic/user-messages';
import { useModel } from '../../../../generic/model-store';
@@ -7,7 +7,7 @@ const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
function useCourseStartAlert(courseId) {
const {
isEnrolled,
} = useModel('courseHomeMeta', courseId);
} = useModel('courses', courseId);
const {
datesWidget: {
courseDateBlocks,
@@ -18,14 +18,14 @@ function useCourseStartAlert(courseId) {
const startBlock = courseDateBlocks.find(b => b.dateType === 'course-start-date');
const delta = startBlock ? new Date(startBlock.date) - new Date() : 0;
const isVisible = isEnrolled && startBlock && delta > 0;
const payload = {
startDate: startBlock && startBlock.date,
userTimezone,
};
useAlert(isVisible, {
code: 'clientCourseStartAlert',
payload: useMemo(() => payload, Object.values(payload).sort()),
payload: {
delta,
startDate: startBlock && startBlock.date,
userTimezone,
},
topic: 'outline-course-alerts',
});

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { usePrivateCourseAlert as default } from './hooks';

View File

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

View File

@@ -5,21 +5,6 @@ const messages = defineMessages({
id: 'learning.outline.dates.all',
defaultMessage: 'View all course dates',
},
collapseAll: {
id: 'learning.outline.collapseAll',
defaultMessage: 'Collapse all',
description: 'Label for button to close all of the collapsible sections',
},
completedAssignment: {
id: 'learning.outline.completedAssignment',
defaultMessage: 'Completed',
description: 'Text used to describe the green checkmark icon in front of an assignment title',
},
completedSection: {
id: 'learning.outline.completedSection',
defaultMessage: 'Completed section',
description: 'Text used to describe the green checkmark icon in front of a section title',
},
dates: {
id: 'learning.outline.dates',
defaultMessage: 'Upcoming Dates',
@@ -29,11 +14,6 @@ const messages = defineMessages({
defaultMessage: 'Edit goal',
description: 'Edit course goal button',
},
expandAll: {
id: 'learning.outline.expandAll',
defaultMessage: 'Expand all',
description: 'Label for button to open all of the collapsible sections',
},
goal: {
id: 'learning.outline.goal',
defaultMessage: 'Goal',
@@ -43,32 +23,18 @@ const messages = defineMessages({
id: 'learning.outline.goalUnsure',
defaultMessage: 'Not sure yet',
},
goalWelcome: {
id: 'learning.outline.goalWelcome',
defaultMessage: 'Welcome to',
description: 'This precedes the title of the course',
},
handouts: {
id: 'learning.outline.handouts',
defaultMessage: 'Course Handouts',
},
incompleteAssignment: {
id: 'learning.outline.incompleteAssignment',
defaultMessage: 'Incomplete',
description: 'Text used to describe the gray checkmark icon in front of an assignment title',
},
incompleteSection: {
id: 'learning.outline.incompleteSection',
defaultMessage: 'Incomplete section',
description: 'Text used to describe the gray checkmark icon in front of a section title',
},
learnMore: {
id: 'learning.outline.learnMore',
defaultMessage: 'Learn More',
},
openSection: {
id: 'learning.outline.altText.openSection',
defaultMessage: 'Open',
description: 'A button to open the given section of the course outline',
},
resume: {
id: 'learning.outline.resume',
defaultMessage: 'Resume course',
defaultMessage: 'Resume Course',
},
setGoal: {
id: 'learning.outline.setGoal',
@@ -82,19 +48,6 @@ const messages = defineMessages({
id: 'learning.outline.tools',
defaultMessage: 'Course Tools',
},
upgradeButton: {
id: 'learning.outline.upgradeButton',
defaultMessage: 'Upgrade ({symbol}{price})',
},
upgradeTitle: {
id: 'learning.outline.upgradeTitle',
defaultMessage: 'Pursue a verified certificate',
},
certAlt: {
id: 'learning.outline.certificateAlt',
defaultMessage: 'Example Certificate',
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
},
welcomeMessage: {
id: 'learning.outline.welcomeMessage',
defaultMessage: 'Welcome Message',
@@ -107,87 +60,6 @@ const messages = defineMessages({
id: 'learning.outline.welcomeMessageShowLessButton',
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;

View File

@@ -9,30 +9,20 @@ import { useModel } from '../../../generic/model-store';
function CourseDates({ courseId, intl }) {
const {
datesWidget: {
courseDateBlocks,
datesTabLink,
userTimezone,
},
datesWidget,
} = useModel('outline', courseId);
if (courseDateBlocks.length === 0) {
return null;
}
return (
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.dates)}</h2>
<ol className="list-unstyled">
{courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={userTimezone}
/>
))}
</ol>
<a className="font-weight-bold ml-4 pl-1 small" href={datesTabLink}>
<section className="mb-3">
<h2 className="h6">{intl.formatMessage(messages.dates)}</h2>
{datesWidget.courseDateBlocks.map((courseDateBlock) => (
<DateSummary
key={courseDateBlock.title + courseDateBlock.date}
dateBlock={courseDateBlock}
userTimezone={datesWidget.userTimezone}
/>
))}
<a className="font-weight-bold ml-4 pl-2" href={datesWidget.datesTabLink}>
{intl.formatMessage(messages.allDates)}
</a>
</section>

View File

@@ -34,11 +34,11 @@ function CourseGoalCard({
}
return (
<Card className="mb-3" data-testid="course-goal-card">
<Card className="mb-3">
<Card.Body>
<div className="row w-100 m-0 justify-content-between align-items-center">
<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 className="col col-auto p-0">
<Button
@@ -54,7 +54,7 @@ function CourseGoalCard({
</Button>
</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">
{goalOptions.map((goal) => {
const [goalKey, goalText] = goal;

View File

@@ -17,10 +17,9 @@ function CourseHandouts({ courseId, intl }) {
}
return (
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.handouts)}</h2>
<section className="mb-3">
<h2 className="h6">{intl.formatMessage(messages.handouts)}</h2>
<LmsHtmlFragment
className="small"
html={handoutsHtml}
title={intl.formatMessage(messages.handouts)}
/>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -14,21 +14,14 @@ import messages from '../messages';
import { useModel } from '../../../generic/model-store';
function CourseTools({ courseId, intl }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
courseTools,
} = useModel('outline', courseId);
if (courseTools.length === 0) {
return null;
}
const logClick = (analyticsId) => {
const { administrator } = getAuthenticatedUser();
sendTrackingLogEvent('edx.course.tool.accessed', {
org_key: org,
courserun_key: courseId,
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
sendTrackEvent('edx.course.tool.accessed', {
course_id: courseId,
is_staff: administrator,
tool_name: analyticsId,
});
@@ -54,18 +47,16 @@ function CourseTools({ courseId, intl }) {
};
return (
<section className="mb-4">
<h2 className="h4">{intl.formatMessage(messages.tools)}</h2>
<ul className="list-unstyled">
{courseTools.map((courseTool) => (
<li key={courseTool.analyticsId} className="small">
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" fixedWidth />
{courseTool.title}
</a>
</li>
))}
</ul>
<section className="mb-3">
<h2 className="h6">{intl.formatMessage(messages.tools)}</h2>
{courseTools.map((courseTool) => (
<div key={courseTool.analyticsId}>
<a href={courseTool.url} onClick={() => logClick(courseTool.analyticsId)}>
<FontAwesomeIcon icon={renderIcon(courseTool.analyticsId)} className="mr-2" style={{ width: '20px' }} />
{courseTool.title}
</a>
</div>
))}
</section>
);
}

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import { Button, Card, Input } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import messages from '../messages';
import { saveCourseGoal } from '../../data';
@@ -15,14 +17,18 @@ function UpdateGoalSelector({
setGoalToDisplay,
setGoalToastHeader,
}) {
const [editingGoal, setEditingGoal] = useState(false);
function selectGoalHandler(event) {
const key = event.currentTarget.id;
const text = event.currentTarget.innerText;
const key = event.currentTarget.value;
const { options } = event.currentTarget;
const { text } = options[options.selectedIndex];
const newGoal = {
key,
text,
};
setEditingGoal(false);
setGoalToDisplay(newGoal);
saveCourseGoal(courseId, key).then((response) => {
const { data } = response;
@@ -36,31 +42,47 @@ function UpdateGoalSelector({
return (
<>
<section className="mb-4">
<section className="mb-3">
<div className="row w-100 m-0">
<div className="col-12 p-0">
<label className="h4 m-0" htmlFor="edit-goal-selector">
<label className="h6" htmlFor="edit-goal-selector">
{intl.formatMessage(messages.goal)}
</label>
</div>
<div className="col-12 p-0">
<Dropdown className="py-2">
<Dropdown.Toggle variant="outline-primary" block id="edit-goal-selector" data-testid="edit-goal-selector">
{selectedGoal.text}
</Dropdown.Toggle>
<Dropdown.Menu>
{goalOptions.map(([goalKey, goalText]) => (
<Dropdown.Item
id={goalKey}
key={goalKey}
onClick={(event) => { selectGoalHandler(event); }}
role="button"
<Card>
<Card.Body className="px-3 py-2">
<div className="row w-100 m-0 justify-content-between align-items-center">
<div className="col-10 p-0">
{!editingGoal && (
<p className="m-0">{selectedGoal.text}</p>
)}
{editingGoal && (
<Input
id="edit-goal-selector"
type="select"
defaultValue={selectedGoal.key}
onBlur={() => { setEditingGoal(false); }}
onChange={(event) => { selectGoalHandler(event); }}
options={goalOptions.map(([goalKey, goalText]) => (
{ value: goalKey, label: goalText }
))}
autoFocus
/>
)}
</div>
<Button
aria-label={intl.formatMessage(messages.editGoal)}
className="p-1"
size="sm"
variant="light"
onClick={() => { setEditingGoal(true); }}
>
{goalText}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
<FontAwesomeIcon icon={faPencilAlt} />
</Button>
</div>
</Card.Body>
</Card>
</div>
</div>
</section>

View File

@@ -1,100 +0,0 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import messages from '../messages';
import { useModel } from '../../../generic/model-store';
import { UpgradeButton } from '../../../generic/upgrade-button';
import VerifiedCert from '../../../generic/assets/edX_certificate.png';
function UpgradeCard({ courseId, intl, onLearnMore }) {
const { org } = useModel('courseHomeMeta', courseId);
const {
offer,
verifiedMode,
} = useModel('outline', courseId);
if (!verifiedMode) {
return null;
}
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const promotionEventProperties = {
creative: 'sidebarupsell',
name: 'In-Course Verification Prompt',
position: 'sidebar-message',
promotion_id: 'courseware_verified_certificate_upsell',
...eventProperties,
};
useEffect(() => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.displayed', eventProperties);
sendTrackEvent('Promotion Viewed', promotionEventProperties);
});
const logClick = () => {
sendTrackingLogEvent('edx.bi.course.upgrade.sidebarupsell.clicked', eventProperties);
sendTrackingLogEvent('edx.course.enrollment.upgrade.clicked', {
...eventProperties,
location: 'sidebar-message',
});
sendTrackEvent('Promotion Clicked', promotionEventProperties);
};
return (
<section className="mb-4 p-3 outline-sidebar-upgrade-card">
<h2 className="h4" 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
alt={intl.formatMessage(messages.certAlt)}
className="w-100"
src={VerifiedCert}
style={{ maxWidth: '10rem' }}
/>
</div>
<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">
<div className="row w-100 m-0 justify-content-center">
<UpgradeButton
offer={offer}
onClick={logClick}
verifiedMode={verifiedMode}
/>
{onLearnMore && (
<div className="col-12">
<Button
variant="link"
size="sm"
className="pb-0"
onClick={onLearnMore}
aria-labelledby="outline-sidebar-upgrade-header"
>
{intl.formatMessage(messages.learnMore)}
</Button>
</div>
)}
</div>
</div>
</div>
</section>
);
}
UpgradeCard.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLearnMore: PropTypes.func,
};
UpgradeCard.defaultProps = {
onLearnMore: null,
};
export default injectIntl(UpgradeCard);

View File

@@ -1,4 +0,0 @@
.outline-sidebar-upgrade-card {
border: 1px solid $dark-500;
border-top: 5px solid $dark-500;
}

View File

@@ -2,8 +2,6 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, TransitionReplace } from '@edx/paragon';
import truncate from 'truncate-html';
import { useDispatch } from 'react-redux';
import LmsHtmlFragment from '../LmsHtmlFragment';
@@ -23,9 +21,8 @@ function WelcomeMessage({ courseId, intl }) {
const [display, setDisplay] = useState(true);
const shortWelcomeMessageHtml = truncate(welcomeMessageHtml, 100, { byWords: true, keepWhitespaces: true });
const messageCanBeShortened = shortWelcomeMessageHtml.length < welcomeMessageHtml.length;
const [showShortMessage, setShowShortMessage] = useState(messageCanBeShortened);
const shortWelcomeMessageHtml = welcomeMessageHtml.length > 200 && `${welcomeMessageHtml.substring(0, 199)}...`;
const [showShortMessage, setShowShortMessage] = useState(!!shortWelcomeMessageHtml);
const dispatch = useDispatch();
return (
@@ -37,40 +34,27 @@ function WelcomeMessage({ courseId, intl }) {
setDisplay(false);
dispatch(dismissWelcomeMessage(courseId));
}}
footer={messageCanBeShortened && (
<div className="row w-100 m-0">
<div className="col-12 col-sm-auto p-0">
<Button
block
>
<div className="my-3">
<LmsHtmlFragment
html={showShortMessage ? shortWelcomeMessageHtml : welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
</div>
{
shortWelcomeMessageHtml && (
<div className="d-flex justify-content-end">
<button
type="button"
className="btn rounded align-self-center border border-primary bg-white font-weight-bold mb-3"
onClick={() => setShowShortMessage(!showShortMessage)}
variant="outline-primary"
>
{showShortMessage ? intl.formatMessage(messages.welcomeMessageShowMoreButton)
: intl.formatMessage(messages.welcomeMessageShowLessButton)}
</Button>
</button>
</div>
</div>
)}
>
<TransitionReplace className="mb-3" enterDuration={200} exitDuration={200}>
{showShortMessage ? (
<LmsHtmlFragment
className="inline-link"
data-testid="short-welcome-message-iframe"
key="short-html"
html={shortWelcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
) : (
<LmsHtmlFragment
className="inline-link"
data-testid="long-welcome-message-iframe"
key="full-html"
html={welcomeMessageHtml}
title={intl.formatMessage(messages.welcomeMessage)}
/>
)}
</TransitionReplace>
)
}
</Alert>
)
);

View File

@@ -6,7 +6,7 @@ import { requestCert } from '../data/thunks';
import { useModel } from '../../generic/model-store';
import messages from './messages';
import VerifiedCert from '../../generic/assets/edX_certificate.png';
import VerifiedCert from '../../courseware/course/sequence/lock-paywall/assets/edx-verified-mini-cert.png';
function CertificateBanner({ intl }) {
const {

View File

@@ -234,7 +234,6 @@ class CoursewareContainer extends Component {
courseId={courseId}
unitId={routeUnitId}
courseStatus={courseStatus}
metadataModel="coursewareMeta"
>
<Course
courseId={courseId}
@@ -297,7 +296,7 @@ CoursewareContainer.defaultProps = {
};
const currentCourseSelector = createSelector(
(state) => state.models.coursewareMeta || {},
(state) => state.models.courses || {},
(state) => state.courseware.courseId,
(coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
);

View File

@@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
import { UserMessagesProvider } from '../generic/user-messages';
import tabMessages from '../tab-page/messages';
import { initializeMockApp } from '../setupTest';
import initializeMockApp from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory';

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { Switch, useRouteMatch } from 'react-router';
import { Switch, Route, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageRoute } from '@edx/frontend-platform/react';
import PageLoading from '../generic/PageLoading';
import CoursewareRedirect from './CoursewareRedirect';
@@ -21,17 +20,17 @@ export default () => {
/>
<Switch>
<PageRoute
<Route
path={`${path}/courseware/:courseId/unit/:unitId`}
component={CoursewareRedirect}
/>
<PageRoute
<Route
path={`${path}/course-home/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
}}
/>
<PageRoute
<Route
path={`${path}/dashboard`}
render={({ location }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
@@ -11,9 +11,9 @@ import useOfferAlert from '../../alerts/offer-alert';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseSock from '../../generic/course-sock';
import CourseSock from './course-sock';
import ContentTools from './content-tools';
import { useModel } from '../../generic/model-store';
function Course({
@@ -24,7 +24,7 @@ function Course({
previousSequenceHandler,
unitNavigationHandler,
}) {
const course = useModel('coursewareMeta', courseId);
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
@@ -35,52 +35,21 @@ function Course({
].filter(element => element != null).map(element => element.title);
const {
accessExpiration,
canShowUpgradeSock,
celebrations,
offer,
org,
userTimezone,
courseExpiredMessage,
offerHtml,
verifiedMode,
} = course;
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
const offerAlert = useOfferAlert(offer, userTimezone, 'course');
const accessExpirationAlert = useAccessExpirationAlert(accessExpiration, userTimezone, 'course');
const offerAlert = useOfferAlert(offerHtml, 'course');
const accessExpirationAlert = useAccessExpirationAlert(courseExpiredMessage, 'course');
const dispatch = useDispatch();
const celebrateFirstSection = celebrations && celebrations.firstSection;
const celebrationOpen = shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch);
// The below block of code should be reverted after the REV1512 experiment
const [REV1512FlyoverEnabled, setREV1512FlyoverEnabled] = useState(false);
window.enableREV1512Flyover = () => {
setREV1512FlyoverEnabled(true);
};
const getCookie = (name) => {
const match = document.cookie.match(`${name}=([^;]*)`);
return match ? match[1] : undefined;
};
const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
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 toggleREV1512Flyover = () => {
const setCookie = (cookieName, value, domain) => {
const cookieDomain = (typeof domain === 'undefined') ? '' : `domain=${domain};`;
const exdate = new Date();
exdate.setHours(exdate.getHours() + 4);
const cookieValue = `${escape(value)}; expires=${exdate.toUTCString()}`;
document.cookie = `${cookieName}=${cookieValue};${cookieDomain}path=/`;
};
const isVisible = isREV1512FlyoverVisible();
setCookie(`REV1512FlyoverVisible${courseId}`, !isVisible);
setREV1512FlyoverVisible(!isVisible);
};
// The above block of code should be reverted after the REV1512 experiment
return (
<>
<Helmet>
@@ -98,22 +67,15 @@ function Course({
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
toggleREV1512Flyover={toggleREV1512Flyover} /* 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" />
<Sequence
unitId={unitId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
courseId={courseId}
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
toggleREV1512Flyover={toggleREV1512Flyover} /* 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 */
/>
{celebrationOpen && (
<CelebrationModal
@@ -121,15 +83,7 @@ function Course({
open
/>
)}
{canShowUpgradeSock && (
<CourseSock
courseId={courseId}
offer={offer}
orgKey={org}
pageLocation="Course Content Page"
verifiedMode={verifiedMode}
/>
)}
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
<ContentTools course={course} />
</>
);

View File

@@ -1,146 +0,0 @@
import React from 'react';
import { Factory } from 'rosie';
import {
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
} from '../../setupTest';
import Course from './Course';
import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils';
jest.mock('@edx/frontend-platform/analytics');
const recordFirstSectionCelebration = jest.fn();
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => {
let store;
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
unitNavigationHandler: () => {},
};
beforeAll(async () => {
store = await initializeTestStore();
const { courseware, models } = store.getState();
const { courseId, sequenceId } = courseware;
Object.assign(mockData, {
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
});
});
it('loads learning sequence', async () => {
render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
});
it('displays celebration modal', async () => {
// TODO: Remove these console mocks after merging https://github.com/edx/paragon/pull/526.
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore });
const celebrationModal = screen.getByRole('dialog');
expect(celebrationModal).toBeInTheDocument();
expect(getByRole(celebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
it('displays upgrade sock', async () => {
const courseMetadata = Factory.build('courseMetadata', { can_show_upgrade_sock: true });
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
});
it('displays offer and expiration alert', async () => {
const courseMetadata = Factory.build('courseMetadata', {
access_expiration: {
expiration_date: '2080-01-01T12:00:00Z',
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);
render(<Course {...mockData} courseId={courseMetadata.id} />, { store: testStore });
await screen.findByText('EDXWELCOME');
await screen.findByText('Audit Access Expires');
});
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();
const unitNavigationHandler = jest.fn();
const courseMetadata = Factory.build('courseMetadata');
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
'block',
{ type: 'vertical' },
{ courseId: courseMetadata.id },
));
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore });
loadUnit();
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button));
screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
});

View File

@@ -13,10 +13,10 @@ function CourseBreadcrumb({
return (
<>
{withSeparator && (
<li className="mx-2 text-primary-500" role="presentation" aria-hidden>/</li>
<li className="mx-2 text-gray-300" role="presentation" aria-hidden>/</li>
)}
<li {...attrs}>
<a className="text-primary-500" href={url}>{children}</a>
<a href={url}>{children}</a>
</li>
</>
);
@@ -36,11 +36,8 @@ export default function CourseBreadcrumbs({
courseId,
sectionId,
sequenceId,
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 course = useModel('coursewareMeta', courseId);
const course = useModel('courses', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sectionId);
const courseStatus = useSelector(state => state.courseware.courseStatus);
@@ -57,12 +54,6 @@ export default function CourseBreadcrumbs({
return [];
}, [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 (
<nav aria-label="breadcrumb" className="my-4">
<ol className="list-unstyled d-flex m-0">
@@ -91,22 +82,6 @@ export default function CourseBreadcrumbs({
{label}
</CourseBreadcrumb>
))}
{/* The below block of code should be reverted after the REV1512 experiment */}
{REV1512FlyoverEnabled
&& !isMobile && (
<div
className="toggleFlyoverButton"
aria-hidden="true"
style={{ marginLeft: 'auto', marginTop: '-16px', 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>
)}
</ol>
</nav>
);
@@ -116,9 +91,6 @@ CourseBreadcrumbs.propTypes = {
courseId: PropTypes.string.isRequired,
sectionId: PropTypes.string,
sequenceId: PropTypes.string,
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 */
};
CourseBreadcrumbs.defaultProps = {

View File

@@ -41,7 +41,7 @@ export default function BookmarkButton({
return (
<StatefulButton
variant="link"
className="px-1 ml-n1 btn-sm text-primary-500"
className="px-1 ml-n1 btn-sm"
onClick={toggleBookmark}
state={state}
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}

View File

@@ -1,94 +0,0 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import { Factory } from 'rosie';
import {
render, screen, fireEvent, initializeTestStore, waitFor, authenticatedUser, logUnhandledRequests,
} from '../../../setupTest';
import { BookmarkButton } from './index';
describe('Bookmark Button', () => {
let axiosMock;
let store;
const courseMetadata = Factory.build('courseMetadata');
const mockData = {
isProcessing: false,
};
const nonBookmarkedUnitBlock = Factory.build(
'block',
{ type: 'vertical' },
{ courseId: courseMetadata.id },
);
const bookmarkedUnitBlock = Factory.build(
'block',
{ type: 'vertical', bookmarked: true },
{ courseId: courseMetadata.id },
);
const unitBlocks = [nonBookmarkedUnitBlock, bookmarkedUnitBlock];
beforeEach(async () => {
store = await initializeTestStore({ courseMetadata, unitBlocks });
mockData.unitId = nonBookmarkedUnitBlock.id;
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const bookmarkUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
axiosMock.onPost(bookmarkUrl).reply(200, { });
const bookmarkDeleteUrlRegExp = new RegExp(`${bookmarkUrl}*,*`);
axiosMock.onDelete(bookmarkDeleteUrlRegExp).reply(200, { });
logUnhandledRequests(axiosMock);
});
it('handles adding bookmark', async () => {
render(<BookmarkButton {...mockData} />);
const button = screen.getByRole('button', { name: 'Bookmark this page' });
expect(button).not.toHaveClass('disabled');
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.post).toHaveLength(1));
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ usage_id: nonBookmarkedUnitBlock.id }));
expect(store.getState().models.units[nonBookmarkedUnitBlock.id].bookmarked).toBeTruthy();
});
it('does not handle adding bookmark when processing', async () => {
render(<BookmarkButton {...mockData} isProcessing />);
const button = screen.getByRole('button', { name: 'Bookmark this page' });
expect(button).toHaveClass('disabled');
fireEvent.click(button);
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(axiosMock.history.post).toHaveLength(1),
{ timeout: 100 },
)).rejects.toThrowError(/expect.*toHaveLength.*/);
expect(store.getState().models.units[nonBookmarkedUnitBlock.id].bookmarked).toBeFalsy();
});
it('handles removing bookmark', async () => {
render(<BookmarkButton {...mockData} unitId={bookmarkedUnitBlock.id} isBookmarked />);
const button = screen.getByRole('button', { name: 'Bookmarked' });
fireEvent.click(button);
await waitFor(() => expect(axiosMock.history.delete).toHaveLength(1));
expect(axiosMock.history.delete[0].url).toContain(`${authenticatedUser.username},${bookmarkedUnitBlock.id}`);
expect(store.getState().models.units[bookmarkedUnitBlock.id].bookmarked).toBeFalsy();
});
it('does not handle removing bookmark when processing', async () => {
render(<BookmarkButton {...mockData} unitId={bookmarkedUnitBlock.id} isBookmarked isProcessing />);
const button = screen.getByRole('button', { name: 'Bookmarked' });
expect(button).toHaveClass('disabled');
fireEvent.click(button);
// HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`.
await expect(waitFor(
() => expect(axiosMock.history.delete).toHaveLength(1),
{ timeout: 100 },
)).rejects.toThrowError(/expect.*toHaveLength.*/);
expect(store.getState().models.units[bookmarkedUnitBlock.id].bookmarked).toBeTruthy();
});
});

View File

@@ -7,7 +7,7 @@ import * as thunks from './thunks';
import executeThunk from '../../../../utils';
import { initializeMockApp } from '../../../../setupTest';
import initializeMockApp from '../../../../setupTest';
import initializeStore from '../../../../store';
const { loggingService } = initializeMockApp();

View File

@@ -7,15 +7,12 @@ import { layoutGenerator } from 'react-break';
import ClapsMobile from './assets/claps_280x201.gif';
import ClapsTablet from './assets/claps_456x328.gif';
import messages from './messages';
import SocialIcons from '../../social-share/SocialIcons';
import SocialIcons from './SocialIcons';
import { recordFirstSectionCelebration } from './utils';
import { useModel } from '../../../generic/model-store';
function CelebrationModal({
courseId, intl, open, ...rest
}) {
const { org } = useModel('coursewareMeta', courseId);
const layout = layoutGenerator({
mobile: 0,
tablet: 400,
@@ -26,7 +23,7 @@ function CelebrationModal({
useEffect(() => {
if (open) {
recordFirstSectionCelebration(org, courseId);
recordFirstSectionCelebration(courseId);
}
}, [open]);
@@ -44,12 +41,7 @@ function CelebrationModal({
<p className="mt-3">
<strong>{intl.formatMessage(messages.earned)}</strong> {intl.formatMessage(messages.share)}
</p>
<SocialIcons
analyticsId="edx.ui.lms.celebration.social_share.clicked"
courseId={courseId}
emailSubject={messages.emailSubject}
socialMessage={messages.socialMessage}
/>
<SocialIcons courseId={courseId} />
</>
)}
closeText={intl.formatMessage(messages.forward)}

View File

@@ -17,23 +17,13 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import { useModel } from '../../generic/model-store';
import { useModel } from '../../../generic/model-store';
function SocialIcons({
analyticsId,
className,
courseId,
emailBody,
emailSubject,
hashtags,
intl,
socialMessage,
}) {
function SocialIcons({ courseId, intl }) {
const {
marketingUrl,
org,
title,
} = useModel('coursewareMeta', courseId);
} = useModel('courses', courseId);
if (!marketingUrl) {
return null;
@@ -43,40 +33,30 @@ function SocialIcons({
const twitterAccount = twitterUrl && twitterUrl.substring(twitterUrl.lastIndexOf('/') + 1);
const logClick = (service) => {
if (!analyticsId) {
return;
}
const { administrator } = getAuthenticatedUser();
sendTrackEvent(analyticsId, {
org_key: org,
courserun_key: courseId,
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
sendTrackEvent('edx.ui.lms.celebration.social_share.clicked', {
course_id: courseId,
is_staff: administrator,
service,
});
};
const socialUtmCampaign = getConfig().SOCIAL_UTM_MILESTONE_CAMPAIGN
? `utm_campaign=${getConfig().SOCIAL_UTM_MILESTONE_CAMPAIGN}&` : '';
const socialUtmMarketingUrl = `${marketingUrl}?${socialUtmCampaign}utm_medium=social`;
return (
<div className={`social-icons ${className}`}>
<div className="social-icons">
<LinkedinShareButton
beforeOnClick={() => logClick('linkedin')}
url={`${socialUtmMarketingUrl}&utm_source=linkedin`}
url={marketingUrl}
>
<LinkedinIcon round size={32} />
<span className="sr-only">{intl.formatMessage(messages.shareService, { service: 'LinkedIn' })}</span>
</LinkedinShareButton>
{twitterAccount && (
{ twitterAccount && (
<TwitterShareButton
beforeOnClick={() => logClick('twitter')}
className="ml-2"
hashtags={hashtags}
title={socialMessage ? intl.formatMessage(socialMessage, { platform: `@${twitterAccount}`, title }) : ''}
url={`${socialUtmMarketingUrl}&utm_source=twitter`}
hashtags={['mooc']}
title={intl.formatMessage(messages.social, { platform: `@${twitterAccount}`, title })}
url={marketingUrl}
>
<TwitterIcon round size={32} />
<span className="sr-only">{intl.formatMessage(messages.shareService, { service: 'Twitter' })}</span>
@@ -85,18 +65,18 @@ function SocialIcons({
<FacebookShareButton
beforeOnClick={() => logClick('facebook')}
className="ml-2"
quote={socialMessage ? intl.formatMessage(socialMessage, { platform: getConfig().SITE_NAME, title }) : ''}
url={`${socialUtmMarketingUrl}&utm_source=facebook`}
quote={intl.formatMessage(messages.social, { platform: getConfig().SITE_NAME, title })}
url={marketingUrl}
>
<FacebookIcon round size={32} />
<span className="sr-only">{intl.formatMessage(messages.shareService, { service: 'Facebook' })}</span>
</FacebookShareButton>
<EmailShareButton
beforeOnClick={() => logClick('email')}
body={emailBody ? `${intl.formatMessage(emailBody)}\n\n` : ''}
body={intl.formatMessage(messages.emailBody)}
className="ml-2"
subject={emailSubject ? intl.formatMessage(emailSubject, { platform: getConfig().SITE_NAME, title }) : ''}
url={`${marketingUrl}?${socialUtmCampaign}utm_medium=email&utm_source=email`}
subject={intl.formatMessage(messages.emailSubject, { platform: getConfig().SITE_NAME, title })}
url={marketingUrl}
>
<EmailIcon round size={32} />
<span className="sr-only">{intl.formatMessage(messages.shareEmail)}</span>
@@ -105,24 +85,9 @@ function SocialIcons({
);
}
SocialIcons.defaultProps = {
analyticsId: '',
className: '',
emailBody: messages.defaultEmailBody,
emailSubject: null,
hashtags: [getConfig().TWITTER_HASHTAG],
socialMessage: null,
};
SocialIcons.propTypes = {
analyticsId: PropTypes.string,
className: PropTypes.string,
courseId: PropTypes.string.isRequired,
emailBody: PropTypes.shape({}),
emailSubject: PropTypes.shape({}),
hashtags: PropTypes.arrayOf(PropTypes.string),
intl: intlShape.isRequired,
socialMessage: PropTypes.shape({}),
};
export default injectIntl(SocialIcons);

View File

@@ -13,9 +13,14 @@ const messages = defineMessages({
id: 'learning.celebration.earned',
defaultMessage: 'You earned it!',
},
emailBody: {
id: 'learning.celebration.emailBody',
defaultMessage: 'What are you spending your time learning?',
description: 'Body when sharing course progress via email',
},
emailSubject: {
id: 'learning.celebration.emailSubject',
defaultMessage: "I'm on my way to completing {title} online with {platform}!",
defaultMessage: "I'm on my way to completing {title} online with @edxonline!",
description: 'Subject when sharing course progress via email',
},
forward: {
@@ -27,7 +32,15 @@ const messages = defineMessages({
id: 'learning.celebration.share',
defaultMessage: 'Take a moment to celebrate and share your progress.',
},
socialMessage: {
shareEmail: {
id: 'learning.celebration.share.email',
defaultMessage: 'Share your progress via email.',
},
shareService: {
id: 'learning.celebration.share.service',
defaultMessage: 'Share your progress on {service}.',
},
social: {
id: 'learning.celebration.social',
defaultMessage: 'Im on my way to completing {title} online with {platform}. What are you spending your time learning?',
description: 'Shown when sharing course progress on a social network',

View File

@@ -16,16 +16,14 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) {
});
}
function recordFirstSectionCelebration(org, courseId) {
function recordFirstSectionCelebration(courseId) {
// Tell the LMS
postFirstSectionCelebrationComplete(courseId);
// Tell our analytics
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.ui.lms.celebration.first_section.opened', {
org_key: org,
courserun_key: courseId,
course_id: courseId, // should only be courserun_key, but left as-is for historical reasons
course_id: courseId,
is_staff: administrator,
});
}
@@ -52,7 +50,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir
// Update our local copy of course data from LMS
dispatch(updateModel({
modelType: 'coursewareMeta',
modelType: 'courses',
model: {
id: courseId,
celebrations: {

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { initializeTestStore, render, screen } from '../../../setupTest';
import ContentTools from './ContentTools';
jest.mock('./calculator/Calculator', () => () => <div data-testid="Calculator" />);
jest.mock('./notes-visibility/NotesVisibility', () => () => <div data-testid="NotesVisibility" />);
describe('Content Tools', () => {
const mockData = {
course: {
notes: { enabled: false },
showCalculator: false,
},
};
beforeAll(async () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
});
it('hides content tools', () => {
const { container } = render(<ContentTools {...mockData} />);
expect(container.getElementsByClassName('d-flex')[0]).toBeEmptyDOMElement();
});
it('displays Calculator', () => {
const testData = JSON.parse(JSON.stringify(mockData));
testData.course.showCalculator = true;
render(<ContentTools {...testData} />);
expect(screen.getByTestId('Calculator')).toBeInTheDocument();
expect(screen.queryByTestId('NotesVisibility')).not.toBeInTheDocument();
});
it('displays Notes Visibility', () => {
const testData = JSON.parse(JSON.stringify(mockData));
testData.course.notes.enabled = true;
render(<ContentTools {...testData} />);
expect(screen.getByTestId('NotesVisibility')).toBeInTheDocument();
expect(screen.queryByTestId('Calculator')).not.toBeInTheDocument();
});
});

View File

@@ -98,13 +98,21 @@ class Calculator extends Component {
<FormattedMessage
tagName="h6"
id="calculator.instructions"
defaultMessage="For detailed information, see the {expressions_link}."
defaultMessage="For detailed information, see {expressions_link} in the {edx_guide}."
values={{
expressions_link: (
<a href={getConfig().SUPPORT_URL_CALCULATOR_MATH}>
<a href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/completing_assignments/SFD_mathformatting.html#math-formatting">
<FormattedMessage
id="calculator.instructions.support.title"
defaultMessage="Help Center"
id="calculator.instructions.expressions.link.title"
defaultMessage="Entering Mathematical and Scientific Expressions"
/>
</a>
),
edx_guide: (
<a href="https://edx-guide-for-students.readthedocs.io/en/latest/index.html">
<FormattedMessage
id="calculator.instructions.edx.guide.link.title"
defaultMessage="edX Guide for Students"
/>
</a>
),

View File

@@ -1,79 +0,0 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import Calculator from './Calculator';
import {
initializeTestStore, render, screen, fireEvent, waitFor, logUnhandledRequests,
} from '../../../../setupTest';
describe('Calculator', () => {
let axiosMock;
let equationUrl;
beforeAll(async () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
equationUrl = new RegExp(`${getConfig().LMS_BASE_URL}/calculate*`);
});
it('expands on click', () => {
render(<Calculator />);
expect(screen.queryByRole('button', { name: 'Calculate' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Calculator Instructions' })).not.toBeInTheDocument();
const button = screen.getByRole('button', { name: 'Calculator' });
expect(button.querySelector('svg')).toHaveClass('fa-calculator');
fireEvent.click(button);
expect(button.querySelector('svg')).toHaveClass('fa-times-circle');
expect(screen.getByRole('button', { name: 'Calculate' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Calculator Instructions' })).toBeInTheDocument();
fireEvent.click(button);
expect(button.querySelector('svg')).toHaveClass('fa-calculator');
});
it('displays instructions on click', () => {
render(<Calculator />);
const button = screen.getByRole('button', { name: 'Calculator' });
fireEvent.click(button);
const instructionsButton = screen.getByRole('button', { name: 'Calculator Instructions' });
expect(instructionsButton.querySelector('svg')).toHaveClass('fa-question-circle');
expect(screen.queryByText(/For detailed information, see/)).not.toBeInTheDocument();
fireEvent.click(instructionsButton);
expect(instructionsButton.querySelector('svg')).toHaveClass('fa-times-circle');
expect(screen.getByText(/For detailed information, see/)).toBeInTheDocument();
fireEvent.click(instructionsButton);
expect(instructionsButton.querySelector('svg')).toHaveClass('fa-question-circle');
});
it('handles submitting equation', async () => {
const equation = 'log2(2^10)';
const result = '10';
axiosMock.reset();
axiosMock.onGet(equationUrl).reply(200, { result });
logUnhandledRequests(axiosMock);
render(<Calculator />);
fireEvent.click(screen.getByRole('button', { name: 'Calculator' }));
const input = screen.getByRole('textbox', { name: 'Calculator Input' });
const output = screen.getByRole('textbox', { name: 'Calculator Result' });
const submitButton = screen.getByRole('button', { name: 'Calculate' });
fireEvent.change(input, { target: { value: equation } });
fireEvent.click(submitButton);
await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
expect(axiosMock.history.get[0].url).toContain(escape(equation));
expect(output).toHaveValue(result);
});
});

View File

@@ -57,10 +57,11 @@ class NotesVisibility extends Component {
NotesVisibility.propTypes = {
intl: intlShape.isRequired,
course: PropTypes.shape({
id: PropTypes.string.isRequired,
id: PropTypes.string,
notes: PropTypes.shape({
enabled: PropTypes.bool,
visible: PropTypes.bool,
}).isRequired,
}),
}).isRequired,
};

View File

@@ -1,97 +0,0 @@
import React from 'react';
import { waitFor } from '@testing-library/dom';
import { getConfig } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
fireEvent, initializeTestStore, logUnhandledRequests, render, screen,
} from '../../../../setupTest';
import NotesVisibility from './NotesVisibility';
const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(),
}));
describe('Notes Visibility', () => {
let axiosMock;
let visibilityUrl;
const mockData = {
course: {
id: 'test-course',
notes: {
visible: false,
},
},
};
beforeAll(async () => {
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
// Mock `targetOrigin` of the `postMessage`.
getConfig.mockImplementation(() => originalConfig);
const config = { ...originalConfig };
config.LMS_BASE_URL = `${window.location.protocol}//${window.location.host}`;
getConfig.mockImplementation(() => config);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
visibilityUrl = `${config.LMS_BASE_URL}/courses/${mockData.course.id}/edxnotes/visibility/`;
});
beforeEach(() => {
axiosMock.reset();
axiosMock.onPut(visibilityUrl).reply(200);
logUnhandledRequests(axiosMock);
});
it('hides notes', () => {
render(<NotesVisibility {...mockData} />);
const button = screen.getByRole('switch', { name: 'Show Notes' });
expect(button)
.not.toBeChecked()
.toHaveClass('text-success');
expect(button.querySelector('svg'))
.toHaveClass('fa-pencil-alt')
.toHaveAttribute('aria-hidden', 'true');
});
it('shows notes', () => {
const testData = JSON.parse(JSON.stringify(mockData));
testData.course.notes.visible = true;
render(<NotesVisibility {...testData} />);
const button = screen.getByRole('switch', { name: 'Hide Notes' });
expect(button)
.toBeChecked()
.toHaveClass('text-secondary');
expect(button.querySelector('svg'))
.toHaveClass('fa-pencil-alt')
.toHaveAttribute('aria-hidden', 'true');
});
it('handles click', async () => {
const mockFn = jest.fn();
const frame = document.createElement('iframe');
frame.id = 'unit-iframe';
const { container } = render(<NotesVisibility {...mockData} />);
container.appendChild(frame);
frame.contentWindow.addEventListener('message', e => {
mockFn(e.data);
});
fireEvent.click(screen.getByRole('switch', { name: 'Show Notes' }));
await waitFor(() => expect(mockFn).toHaveBeenCalled());
expect(mockFn)
.toHaveBeenCalledTimes(1)
.toHaveBeenCalledWith('tools.toggleNotes');
expect(axiosMock.history.put).toHaveLength(1);
expect(axiosMock.history.put[0].url).toEqual(visibilityUrl);
expect(axiosMock.history.put[0].data).toEqual(`{"visibility":${mockData.course.notes.visible}}`);
expect(screen.getByRole('switch', { name: 'Hide Notes' })).toBeInTheDocument();
});
});

View File

@@ -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' }} />&nbsp;
<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);

View File

@@ -1,364 +0,0 @@
import React, { useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLinkedinIn } from '@fortawesome/free-brands-svg-icons';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { layoutGenerator } from 'react-break';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, Button, Hyperlink } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import CatalogSuggestion from './CatalogSuggestion';
import CelebrationMobile from './assets/celebration_456x328.gif';
import CelebrationDesktop from './assets/celebration_750x540.gif';
import certificate from '../../../generic/assets/edX_certificate.png';
import certificateLocked from '../../../generic/assets/edX_locked_certificate.png';
import { FormattedPricing } from '../../../generic/upgrade-button';
import messages from './messages';
import { useModel } from '../../../generic/model-store';
import { requestCert } from '../../../course-home/data/thunks';
import ProgramCompletion from './ProgramCompletion';
import DashboardFootnote from './DashboardFootnote';
import UpgradeFootnote from './UpgradeFootnote';
import SocialIcons from '../../social-share/SocialIcons';
import { logClick, logVisit } from './utils';
const LINKEDIN_BLUE = '#2867B2';
function CourseCelebration({ intl }) {
const layout = layoutGenerator({
mobile: 0,
tablet: 768,
});
const OnMobile = layout.is('mobile');
const OnAtLeastTablet = layout.isAtLeast('tablet');
const { courseId } = useSelector(state => state.courseware);
const dispatch = useDispatch();
const {
certificateData,
end,
linkedinAddToProfileUrl,
offer,
org,
relatedPrograms,
title,
verifiedMode,
verifyIdentityUrl,
verificationStatus,
} = useModel('coursewareMeta', courseId);
const {
certStatus,
certWebViewUrl,
downloadUrl,
} = certificateData || {};
const { administrator, username } = getAuthenticatedUser();
const dashboardLink = (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/dashboard`}
>
{intl.formatMessage(messages.dashboardLink)}
</Hyperlink>
);
const idVerificationSupportLink = getConfig().SUPPORT_URL_ID_VERIFICATION && (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
destination={getConfig().SUPPORT_URL_ID_VERIFICATION}
>
{intl.formatMessage(messages.idVerificationSupportLink)}
</Hyperlink>
);
const profileLink = (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
destination={`${getConfig().LMS_BASE_URL}/u/${username}`}
>
{intl.formatMessage(messages.profileLink)}
</Hyperlink>
);
let buttonPrefix = null;
let buttonLocation;
let buttonText;
let buttonVariant = 'outline-primary';
let buttonEvent = null;
let buttonSuffix = null;
let certificateImage = certificate;
let footnote;
let message;
let certHeader;
let visitEvent = 'celebration_generic';
// These cases are taken from the edx-platform `get_cert_data` function found in lms/courseware/views/views.py
switch (certStatus) {
case 'downloadable':
certHeader = intl.formatMessage(messages.certificateHeaderDownloadable);
message = (
<p>
<FormattedMessage
id="courseCelebration.certificateBody.available"
defaultMessage="
Showcase your accomplishment on LinkedIn or your resumé today.
You can download your certificate now and access it any time from your
{dashboardLink} and {profileLink}."
values={{ dashboardLink, profileLink }}
/>
</p>
);
if (certWebViewUrl) {
buttonLocation = `${getConfig().LMS_BASE_URL}${certWebViewUrl}`;
buttonText = intl.formatMessage(messages.viewCertificateButton);
} else if (downloadUrl) {
buttonLocation = downloadUrl;
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';
visitEvent = 'celebration_with_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
break;
case 'earned_but_not_available': {
const endDate = <FormattedDate value={end} day="numeric" month="long" year="numeric" />;
certHeader = intl.formatMessage(messages.certificateHeaderNotAvailable);
message = (
<>
<p>
<FormattedMessage
id="courseCelebration.certificateBody.notAvailable.endDate"
defaultMessage="After this course officially ends on {endDate}, you will receive an
email notification with your certificate. Once you have your certificate, be sure
to showcase your accomplishment on LinkedIn or your resumé."
values={{ endDate }}
/>
</p>
<p>
<FormattedMessage
id="courseCelebration.certificateBody.notAvailable.accessCertificate"
defaultMessage="You will be able to access your certificate any time from your
{dashboardLink} and {profileLink}."
values={{ dashboardLink, profileLink }}
/>
</p>
</>
);
visitEvent = 'celebration_with_unavailable_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
break;
}
case 'requesting':
// The requesting status needs a different button because it does a POST instead of a GET.
// So we don't set buttonLocation and instead define a custom button as a buttonPrefix.
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);
message = (<p>{intl.formatMessage(messages.requestCertificateBodyText)}</p>);
visitEvent = 'celebration_with_requestable_cert';
footnote = <DashboardFootnote variant={visitEvent} />;
break;
case 'unverified':
certHeader = intl.formatMessage(messages.certificateHeaderUnverified);
visitEvent = 'celebration_unverified';
footnote = <DashboardFootnote variant={visitEvent} />;
if (verificationStatus === 'pending') {
message = (<p>{intl.formatMessage(messages.verificationPending)}</p>);
} else {
buttonText = intl.formatMessage(messages.verifyIdentityButton);
buttonEvent = 'verify_id';
buttonLocation = verifyIdentityUrl;
// todo: check for idVerificationSupportLink null
message = (
<p>
<FormattedMessage
id="courseCelebration.certificateBody.unverified"
defaultMessage="In order to generate a certificate, you must complete ID verification.
{idVerificationSupportLink} now."
values={{ idVerificationSupportLink }}
/>
</p>
);
}
break;
case 'audit_passing':
case 'honor_passing':
if (verifiedMode) {
certHeader = intl.formatMessage(messages.certificateHeaderUpgradable);
message = (
<p>
<FormattedMessage
id="courseCelebration.certificateBody.upgradable"
defaultMessage="Its not too late to upgrade. For {price} you will unlock access to all graded
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
certificate in school applications."
values={{ price: <FormattedPricing inline offer={offer} verifiedMode={verifiedMode} /> }}
/>
<br />
{getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE && (
<Hyperlink
className="text-gray-700"
style={{ textDecoration: 'underline' }}
destination={getConfig().SUPPORT_URL_VERIFIED_CERTIFICATE}
>
{intl.formatMessage(messages.verifiedCertificateSupportLink)}
</Hyperlink>
)}
</p>
);
buttonText = intl.formatMessage(messages.upgradeButton);
buttonEvent = 'upgrade';
buttonLocation = verifiedMode.upgradeUrl;
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;
visitEvent = 'celebration_upgrade';
if (verifiedMode.accessExpirationDate) {
footnote = <UpgradeFootnote deadline={verifiedMode.accessExpirationDate} href={verifiedMode.upgradeUrl} />;
} else {
footnote = <DashboardFootnote variant={visitEvent} />;
}
} else {
visitEvent = 'celebration_audit_no_upgrade';
}
break;
default:
break;
}
useEffect(() => logVisit(org, courseId, administrator, visitEvent), [org, courseId, administrator, visitEvent]);
return (
<>
<Helmet>
<title>{`${intl.formatMessage(messages.congratulationsHeader)} | ${title} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<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">
{intl.formatMessage(messages.congratulationsHeader)}
</div>
<div className="col-12 p-0 font-weight-normal lead text-center">
{intl.formatMessage(messages.shareHeader)}
<SocialIcons
analyticsId="edx.ui.lms.course_exit.social_share.clicked"
className="mt-2"
courseId={courseId}
emailSubject={messages.socialMessage}
socialMessage={messages.socialMessage}
/>
</div>
<div className="col-12 mt-3 mb-4 px-0 px-md-5 text-center">
<OnMobile>
<img
src={CelebrationMobile}
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
className="img-fluid"
/>
</OnMobile>
<OnAtLeastTablet>
<img
src={CelebrationDesktop}
alt={`${intl.formatMessage(messages.congratulationsImage)}`}
className="img-fluid"
style={{ width: '36rem' }}
/>
</OnAtLeastTablet>
</div>
<div className="col-12 px-0 px-md-5">
{certHeader && (
<Alert variant="primary" className="row w-100 m-0">
<div className="col order-1 order-md-0 pl-0 pr-0 pr-md-5">
<div className="h4">{certHeader}</div>
{message}
<div className="mt-2">
{buttonPrefix}
{buttonLocation && (
<Button
variant={buttonVariant}
href={buttonLocation}
onClick={() => logClick(org, courseId, administrator, buttonEvent)}
>
{buttonText}
</Button>
)}
{buttonSuffix}
</div>
</div>
{certStatus !== 'unverified' && (
<div className="col-12 order-0 col-md-3 order-md-1 w-100 mb-3 p-0 text-center">
<img
src={certificateImage}
alt={`${intl.formatMessage(messages.certificateImage)}`}
className="w-100"
style={{ maxWidth: '13rem' }}
/>
</div>
)}
</Alert>
)}
{relatedPrograms && relatedPrograms.map(program => (
<ProgramCompletion
key={program.uuid}
progress={program.progress}
title={program.title}
type={program.slug}
url={program.url}
/>
))}
{footnote}
<CatalogSuggestion variant={visitEvent} />
</div>
</div>
</>
);
}
CourseCelebration.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CourseCelebration);

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